diff --git a/package.json b/package.json index 8c51e7df449..82cc3a98f39 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-typescript": "^11.1.6", "@symfony/stimulus-testing": "^2.0.1", + "@types/three": "^0.174.0", "@vitest/browser": "^2.1.1", "lightningcss": "^1.28.2", "playwright": "^1.47.0", diff --git a/src/Threejs/.gitattributes b/src/Threejs/.gitattributes new file mode 100644 index 00000000000..ec2de4be5f0 --- /dev/null +++ b/src/Threejs/.gitattributes @@ -0,0 +1,8 @@ +/.git* export-ignore +/.symfony.bundle.yaml export-ignore +/assets/src export-ignore +/assets/test export-ignore +/assets/vitest.config.js export-ignore +/doc export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/src/Threejs/.github/PULL_REQUEST_TEMPLATE.md b/src/Threejs/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Threejs/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Threejs/.github/workflows/close-pull-request.yml b/src/Threejs/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Threejs/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Threejs/.gitignore b/src/Threejs/.gitignore new file mode 100644 index 00000000000..72fd52b30e2 --- /dev/null +++ b/src/Threejs/.gitignore @@ -0,0 +1,6 @@ +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache +/var/ \ No newline at end of file diff --git a/src/Threejs/.symfony.bundle.yaml b/src/Threejs/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Threejs/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Threejs/CHANGELOG.md b/src/Threejs/CHANGELOG.md new file mode 100644 index 00000000000..fff1a446412 --- /dev/null +++ b/src/Threejs/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.24.0 + +Create ux-threejs package \ No newline at end of file diff --git a/src/Threejs/LICENSE b/src/Threejs/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Threejs/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Threejs/README.md b/src/Threejs/README.md new file mode 100644 index 00000000000..fa1ac16cce1 --- /dev/null +++ b/src/Threejs/README.md @@ -0,0 +1,30 @@ +# Symfony UX Three.js + +Symfony UX Three.js is a Symfony bundle integrating the [Three.js](https://threejs.org/) +library in Symfony applications. It is part of [the Symfony UX initiative](https://ux.symfony.com/). + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Sponsor + +The Symfony UX packages are [backed][1] by [Mercure.rocks][2]. + +Create real-time experiences in minutes! Mercure.rocks provides a realtime API service +that is tightly integrated with Symfony: create UIs that update in live with UX Turbo, +send notifications with the Notifier component, expose async APIs with API Platform and +create low level stuffs with the Mercure component. We maintain and scale the complex +infrastructure for you! + +Help Symfony by [sponsoring][3] its development! + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-threejs/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) + +[1]: https://symfony.com/backers +[2]: https://mercure.rocks +[3]: https://symfony.com/sponsor diff --git a/src/Threejs/assets/LICENSE b/src/Threejs/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Threejs/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Threejs/assets/README.md b/src/Threejs/assets/README.md new file mode 100644 index 00000000000..dd569be46bb --- /dev/null +++ b/src/Threejs/assets/README.md @@ -0,0 +1,22 @@ +# @symfony/ux-threejs + +JavaScript assets of the [symfony/ux-threejs](https://packagist.org/packages/symfony/ux-threejs) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-threejs](https://packagist.org/packages/symfony/ux-threejs) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-threejs](https://packagist.org/packages/symfony/ux-threejs) PHP package version: +```shell +composer require symfony/ux-threejs:2.23.0 +npm add @symfony/ux-threejs@2.23.0 +``` + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-threejs/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Threejs/assets/dist/controller.d.ts b/src/Threejs/assets/dist/controller.d.ts new file mode 100644 index 00000000000..fa9751515e8 --- /dev/null +++ b/src/Threejs/assets/dist/controller.d.ts @@ -0,0 +1,54 @@ +import { Controller } from '@hotwired/stimulus'; +import * as THREE from 'three'; +export type Material = { + color: string; + opacity: number; + map: string; + transparent: boolean; + type: string; + doubleSide: boolean; + skybox: boolean; +}; +export type Mesh = { + geometry: any; + material: Material; + animation: any; +}; +export type Light = { + type: String; + color: THREE.Color; + intensity: number; + position: THREE.Vector3; + target: THREE.Vector3; +}; +export type Camera = { + type: String; + position: THREE.Vector3; + near: number; + far: number; + aspect: number; + fov: number; + top: number; + left: number; + right: number; + bottom: number; +}; +export default class extends Controller { + threeValue: any; + static values: { + three: ObjectConstructor; + }; + private renderer; + connect(): void; + createScene(data: any): void; + transform(object3D: THREE.Object3D, transformationData: any): any; + createMesh(meshData: Mesh, scene: THREE.Scene): THREE.Mesh; + createGeometry(geometryData: any): THREE.BufferGeometry; + createMaterial(materialData: Material): THREE.Material | undefined; + createLight(lightData: Light, scene: THREE.Scene): void; + createCamera(cameraData: Camera, renderer: THREE.WebGLRenderer): THREE.Camera; + setControls(controlCamera: THREE.Camera, renderer: THREE.WebGLRenderer): void; + createModel(modelData: any, scene: THREE.Scene): any; + private dispatchEvent; + threeValueChanged(): void; +} diff --git a/src/Threejs/assets/dist/controller.js b/src/Threejs/assets/dist/controller.js new file mode 100644 index 00000000000..d7ad52df243 --- /dev/null +++ b/src/Threejs/assets/dist/controller.js @@ -0,0 +1,208 @@ +import { Controller } from '@hotwired/stimulus'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.renderer = null; + } + connect() { + this.dispatchEvent('pre-connect', { + options: this.threeValue, + }); + const threeValue = this.threeValue; + this.renderer = new THREE.WebGLRenderer(); + this.createScene(threeValue); + } + createScene(data) { + if (this.renderer === null) { + return; + } + const rendererValue = data.renderer; + this.renderer?.setSize(rendererValue.width ?? window.innerWidth, rendererValue.height ?? window.innerHeight); + this.element.appendChild(this.renderer.domElement); + const sceneValue = rendererValue.scene; + let scene = new THREE.Scene(); + const light = new THREE.AmbientLight(0x404040); + scene.add(light); + if (sceneValue.material.color) { + scene.background = new THREE.Color(sceneValue.material.color); + } + if (sceneValue.material.map) { + const texture = new THREE.TextureLoader().load(sceneValue.material.map); + if (sceneValue.material.skybox) + texture.mapping = THREE.EquirectangularReflectionMapping; + scene.background = texture; + } + const cameras = []; + for (let cameraData of rendererValue.cameras) { + cameras.push(this.createCamera(cameraData, this.renderer)); + } + for (let lightData of sceneValue.lights) { + this.createLight(lightData, scene); + } + if (rendererValue.controls) { + this.setControls(cameras[0], this.renderer); + } + for (let modelData of this.threeValue.renderer.scene.models) { + this.createModel(modelData, scene); + } + let animatedObjects = []; + for (let mesh of sceneValue.meshes) { + animatedObjects.push({ mesh: this.createMesh(mesh, scene), animation: mesh.animation }); + } + const animate = () => { + for (let animationObject of animatedObjects) { + const { mesh, animation } = animationObject; + mesh.rotation.x += animation.rotation.x; + mesh.rotation.y += animation.rotation.y; + mesh.rotation.z += animation.rotation.z; + mesh.scale.x += animation.scale.x; + mesh.scale.y += animation.scale.y; + mesh.scale.z += animation.scale.z; + mesh.position.x += animation.translation.x; + mesh.position.y += animation.translation.y; + mesh.position.z = animation.translation.z; + } + this.renderer?.render(scene, cameras[0]); + requestAnimationFrame(animate); + }; + animate(); + this.dispatchEvent('connect', { + renderer: this.renderer, + scene: scene, + }); + } + transform(object3D, transformationData) { + const { position, angle } = transformationData; + object3D.translateX(position.x); + object3D.translateY(position.y); + object3D.translateZ(position.z); + object3D.setRotationFromEuler(new THREE.Euler().setFromVector3(angle)); + } + createMesh(meshData, scene) { + let mesh = new THREE.Mesh(this.createGeometry(meshData.geometry), this.createMaterial(meshData.material)); + this.transform(mesh, meshData); + scene.add(mesh); + return mesh; + } + createGeometry(geometryData) { + if (geometryData.type == 'Sphere') { + const { radius, widthSegments, heightSegments } = geometryData; + return new THREE.SphereGeometry(radius, widthSegments, heightSegments); + } + if (geometryData.type == 'Plane') { + const { width, height, widthSegments, heightSegments } = geometryData; + return new THREE.PlaneGeometry(width, height, widthSegments, heightSegments); + } + if (geometryData.type == 'Cylinder') { + const { radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength } = geometryData; + return new THREE.CylinderGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength); + } + const { width, height, depth } = geometryData; + return new THREE.BoxGeometry(width, height, depth); + } + createMaterial(materialData) { + const { color, opacity, transparent, map, doubleSide } = materialData; + let texture = null; + let material; + if (materialData.type == 'MeshPhong') { + material = new THREE.MeshPhongMaterial({ color, opacity, transparent }); + } + else if (materialData.type == 'MeshBasic') { + material = new THREE.MeshBasicMaterial({ color, opacity, transparent }); + } + else { + return undefined; + } + if (map) { + texture = new THREE.TextureLoader().load(materialData.map); + texture.colorSpace = THREE.SRGBColorSpace; + material.map = texture; + } + if (doubleSide) { + material.side = THREE.DoubleSide; + } + return material; + } + createLight(lightData, scene) { + if (lightData.type == 'Ambient') { + const { color, intensity } = lightData; + const light = new THREE.AmbientLight(color, intensity); + scene.add(light); + } + if (lightData.type == 'Directional') { + const { color, intensity, position, target } = lightData; + const light = new THREE.DirectionalLight(color, intensity); + light.position.set(position.x, position.y, position.z); + light.target.position.set(target.x, target.y, target.z); + scene.add(light.target); + scene.add(light); + } + } + createCamera(cameraData, renderer) { + let camera; + if (cameraData.type == 'Perspective') { + const { fov, near, far } = cameraData; + camera = new THREE.PerspectiveCamera(fov, (renderer.domElement.clientWidth / renderer.domElement.clientHeight), near, far); + } + else { + const { left, right, top, bottom, near, far } = cameraData; + camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far); + } + camera.position.set(cameraData.position.x, cameraData.position.y, cameraData.position.z); + return camera; + } + setControls(controlCamera, renderer) { + const controls = new OrbitControls(controlCamera, renderer.domElement); + controls.listenToKeyEvents(window); + controls.update(); + } + createModel(modelData, scene) { + const { path, animation } = modelData; + let loader = new GLTFLoader(); + loader.load(path, (model) => { + this.transform(model.scene, modelData); + scene.add(model.scene); + model.scene.traverse(function (object) { + }); + const mixer = new THREE.AnimationMixer(model.scene); + const clock = new THREE.Clock(); + if (animation.playClip) { + const clip = model.animations.find(a => a.name === animation.playClip); + if (clip) { + const runAction = mixer.clipAction(clip); + runAction.play(); + } + } + function animate() { + model.scene.rotation.x += animation.rotation.x; + model.scene.rotation.y += animation.rotation.y; + model.scene.rotation.z += animation.rotation.z; + model.scene.translateX(animation.translation.x); + model.scene.translateY(animation.translation.y); + model.scene.translateZ(animation.translation.z); + if (animation.playClip) { + const delta = clock.getDelta(); + mixer.update(delta); + } + requestAnimationFrame(animate); + } + animate(); + }); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'ux:threejs' }); + } + threeValueChanged() { + const threeValue = this.threeValue; + this.createScene(threeValue); + } +} +default_1.values = { + three: Object, +}; + +export { default_1 as default }; diff --git a/src/Threejs/assets/package.json b/src/Threejs/assets/package.json new file mode 100644 index 00000000000..59f09fe076b --- /dev/null +++ b/src/Threejs/assets/package.json @@ -0,0 +1,49 @@ +{ + "name": "@symfony/ux-threejs", + "description": "three integration for Symfony", + "license": "MIT", + "version": "2.23.0", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/threejs", + "repository": "https://github.com/symfony/ux-threejs", + "type": "module", + "files": [ + "dist" + ], + "main": "dist/controller.js", + "types": "dist/controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, + "symfony": { + "controllers": { + "three": { + "main": "dist/controller.js", + "webpackMode": "eager", + "fetch": "eager", + "enabled": true + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "three": "^0.174.0" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "three": "^0.174.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "happy-dom": "^17.4.4", + "resize-observer-polyfill": "^1.5.1", + "three": "^0.174.0", + "vitest-canvas-mock": "^0.3.3" + } +} diff --git a/src/Threejs/assets/src/controller.ts b/src/Threejs/assets/src/controller.ts new file mode 100644 index 00000000000..6e01924d5b4 --- /dev/null +++ b/src/Threejs/assets/src/controller.ts @@ -0,0 +1,316 @@ +import { Controller } from '@hotwired/stimulus'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +export type Material = { + color: string; + opacity: number; + map: string; + transparent: boolean; + type: string; + doubleSide: boolean; + skybox: boolean; +} + +export type Mesh = { + geometry: any; + material: Material; + animation: any; +} + +export type Light = { + type: String; + color: THREE.Color; + intensity: number; + position: THREE.Vector3, + target: THREE.Vector3, +} + +export type Camera = { + type: String; + position: THREE.Vector3, + near: number; + far: number; + aspect: number; + fov: number; + top: number; + left: number; + right: number; + bottom: number; +} + +export default class extends Controller { + declare threeValue: any; + + static values = { + three: Object, + } + private renderer: THREE.WebGLRenderer | null = null; + + connect() { + this.dispatchEvent('pre-connect', { + options: this.threeValue, + }); + + const threeValue = this.threeValue; + this.renderer = new THREE.WebGLRenderer(); + + this.createScene(threeValue); + } + + + createScene(data: any) { + if(this.renderer === null) { + return; + } + /** init renderer */ + const rendererValue = data.renderer; + this.renderer?.setSize(rendererValue.width ?? window.innerWidth, rendererValue.height ?? window.innerHeight); + this.element.appendChild(this.renderer.domElement); + // /** init scene */ + const sceneValue = rendererValue.scene; + let scene = new THREE.Scene(); + const light = new THREE.AmbientLight(0x404040); + scene.add(light); + + if (sceneValue.material.color) { + scene.background = new THREE.Color(sceneValue.material.color); + } + if (sceneValue.material.map) { + const texture = new THREE.TextureLoader().load(sceneValue.material.map); + if (sceneValue.material.skybox) + texture.mapping = THREE.EquirectangularReflectionMapping; + scene.background = texture; + + } + + + /** cameras */ + const cameras: THREE.Camera[] = []; + for (let cameraData of rendererValue.cameras) { + cameras.push(this.createCamera(cameraData, this.renderer)); + } + + /** lights */ + for (let lightData of sceneValue.lights) { + this.createLight(lightData, scene); + } + + /** controls */ + if (rendererValue.controls) { + this.setControls(cameras[0], this.renderer); + } + + /** load 3d models */ + for (let modelData of this.threeValue.renderer.scene.models) { + this.createModel(modelData, scene); + } + + /** load meshes */ + let animatedObjects = []; + for (let mesh of sceneValue.meshes) { + animatedObjects.push({ mesh: this.createMesh(mesh, scene), animation: mesh.animation }); + } + + /** animation */ + const animate = () => { + for (let animationObject of animatedObjects) { + const { mesh, animation } = animationObject; + mesh.rotation.x += animation.rotation.x; + mesh.rotation.y += animation.rotation.y; + mesh.rotation.z += animation.rotation.z; + mesh.scale.x += animation.scale.x; + mesh.scale.y += animation.scale.y; + mesh.scale.z += animation.scale.z; + mesh.position.x += animation.translation.x; + mesh.position.y += animation.translation.y; + mesh.position.z = animation.translation.z; + } + this.renderer?.render(scene, cameras[0]); + + requestAnimationFrame(animate); + }; + + animate(); + + this.dispatchEvent('connect', { + renderer: this.renderer, + scene: scene, + }); + } + + transform(object3D: THREE.Object3D, transformationData: any): any { + const { position, angle } = transformationData; + object3D.translateX(position.x); + object3D.translateY(position.y); + object3D.translateZ(position.z); + object3D.setRotationFromEuler(new THREE.Euler().setFromVector3(angle)); + } + + createMesh(meshData: Mesh, scene: THREE.Scene): THREE.Mesh { + let mesh = new THREE.Mesh( + this.createGeometry(meshData.geometry), + this.createMaterial(meshData.material), + ); + + this.transform(mesh, meshData) + + scene.add(mesh); + + return mesh; + } + + createGeometry(geometryData: any): THREE.BufferGeometry { + + if (geometryData.type == 'Sphere') { + const { radius, widthSegments, heightSegments } = geometryData; + return new THREE.SphereGeometry(radius, widthSegments, heightSegments); + } + if (geometryData.type == 'Plane') { + const { width, height, widthSegments, heightSegments } = geometryData; + return new THREE.PlaneGeometry(width, height, widthSegments, heightSegments); + } + if (geometryData.type == 'Cylinder') { + const { radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength } = geometryData; + return new THREE.CylinderGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength); + } + + const { width, height, depth } = geometryData; + return new THREE.BoxGeometry(width, height, depth); + } + + createMaterial(materialData: Material): THREE.Material | undefined { + const { color, opacity, transparent, map, doubleSide } = materialData; + let texture = null; + + let material; + + if (materialData.type == 'MeshPhong') { + material = new THREE.MeshPhongMaterial({ color, opacity, transparent }); + } else if (materialData.type == 'MeshBasic') { + material = new THREE.MeshBasicMaterial({ color, opacity, transparent }); + } else { + return undefined; + } + + if (map) { + texture = new THREE.TextureLoader().load(materialData.map); + texture.colorSpace = THREE.SRGBColorSpace; + material.map = texture; + } + + if (doubleSide) { + material.side = THREE.DoubleSide; + } + + return material; + } + + createLight(lightData: Light, scene: THREE.Scene) { + if (lightData.type == 'Ambient') { + const { color, intensity } = lightData; + const light = new THREE.AmbientLight(color, intensity); + scene.add(light); + } + if (lightData.type == 'Directional') { + const { color, intensity, position, target } = lightData; + const light = new THREE.DirectionalLight(color, intensity); + light.position.set(position.x, position.y, position.z); + light.target.position.set(target.x, target.y, target.z); + scene.add(light.target); + scene.add(light); + } + } + + createCamera(cameraData: Camera, renderer: THREE.WebGLRenderer): THREE.Camera { + let camera: THREE.Camera; + + if (cameraData.type == 'Perspective') { + const { fov, near, far } = cameraData; + camera = new THREE.PerspectiveCamera(fov, (renderer.domElement.clientWidth / renderer.domElement.clientHeight), near, far); + } else { + const { left, right, top, bottom, near, far } = cameraData; + camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far); + } + + camera.position.set(cameraData.position.x, cameraData.position.y, cameraData.position.z) + return camera; + } + + setControls(controlCamera: THREE.Camera, renderer: THREE.WebGLRenderer): void { + const controls = new OrbitControls(controlCamera, renderer.domElement); + controls.listenToKeyEvents(window); + controls.update(); + } + + createModel(modelData: any, scene: THREE.Scene): any { + const { path, animation } = modelData; + let loader = new GLTFLoader(); + + loader.load(path, (model) => { + this.transform(model.scene, modelData); + scene.add(model.scene); + + model.scene.traverse(function(object) { + if (object instanceof THREE.Mesh) { + // const material = object.material; + // if(material.isMaterial && material.type=='MeshStandardMaterial') { + // material.dispose(); + // object.material = new THREE.MeshBasicMaterial(); + // object.material.color = 'green'; + // } + } + // // Supposons que vous voulez changer la texture du premier matériau trouvé + // const material = child.material; + + // // Charger une nouvelle texture + // const textureLoader = new THREE.TextureLoader(); + // textureLoader.load('path/to/your/new-texture.jpg', function(texture) { + // // Mettre à jour la texture du matériau + // material.map = texture; + // material.needsUpdate = true; // Indiquer que le matériau doit être mis à jour + // }); + // } + }); + + const mixer = new THREE.AnimationMixer(model.scene); + const clock = new THREE.Clock(); + if (animation.playClip) { + const clip = model.animations.find(a => a.name === animation.playClip); + if (clip) { + const runAction = mixer.clipAction(clip); + runAction.play(); + } + } + + function animate() { + model.scene.rotation.x += animation.rotation.x; + model.scene.rotation.y += animation.rotation.y; + model.scene.rotation.z += animation.rotation.z; + model.scene.translateX(animation.translation.x); + model.scene.translateY(animation.translation.y); + model.scene.translateZ(animation.translation.z); + if (animation.playClip) { + const delta = clock.getDelta(); + mixer.update(delta); + } + + requestAnimationFrame(animate); + } + animate(); + }); + } + + private dispatchEvent(name: string, payload: any) { + this.dispatch(name, { detail: payload, prefix: 'ux:threejs' }); + } + + threeValueChanged(): void { + const threeValue = this.threeValue; + + this.createScene(threeValue); + + } +} + diff --git a/src/Threejs/assets/test/controller.test.ts b/src/Threejs/assets/test/controller.test.ts new file mode 100644 index 00000000000..216fb81082f --- /dev/null +++ b/src/Threejs/assets/test/controller.test.ts @@ -0,0 +1,65 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Application , Controller } from '@hotwired/stimulus'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import ThreejsController from '../src/controller'; +class CheckController extends Controller { + connect() { + this.element.addEventListener('ux:threejs:pre-connect', (event) => { + this.element.classList.add('pre-connected'); + }); + + this.element.addEventListener('ux:threejs:connect', (event) => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('symfony--ux-threejs--three', ThreejsController); +}; + +describe('ThreejsController', () => { + let container: HTMLElement; + + beforeEach(() => { + container = mountDOM(` +
+ +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect and create three js scene', async () => { + const div = getByTestId(container, 'three'); + expect(div).not.toHaveClass('pre-connected'); + expect(div).not.toHaveClass('connected'); + + // startStimulus(); + + // await waitFor(() => expect(div).toHaveClass('pre-connected')); + // await waitFor(() => expect(div).toHaveClass('connected')); + // await waitFor(() => expect(application.getControllerForElementAndIdentifier(div, 'three')).not.toBeNull()); + + + }); + + +}); diff --git a/src/Threejs/assets/test/setup.js b/src/Threejs/assets/test/setup.js new file mode 100644 index 00000000000..0b1871a4763 --- /dev/null +++ b/src/Threejs/assets/test/setup.js @@ -0,0 +1,12 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'vitest-canvas-mock'; +// eslint-disable-next-line +global.ResizeObserver = require('resize-observer-polyfill'); diff --git a/src/Threejs/assets/vitest.config.mjs b/src/Threejs/assets/vitest.config.mjs new file mode 100644 index 00000000000..39cf9e4d127 --- /dev/null +++ b/src/Threejs/assets/vitest.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import configShared from '../../../vitest.config.mjs' +import path from 'path'; + +export default mergeConfig( + configShared, + defineConfig({ + test: { + environment: 'happy-dom', // Utilisez jsdom comme environnement principal + setupFiles: [path.join(__dirname, 'test', 'setup.js')], + deps: { + optimizer: { + web: { + include: ['vitest-canvas-mock'], + }, + }, + }, + } + }) +); diff --git a/src/Threejs/composer.json b/src/Threejs/composer.json new file mode 100644 index 00000000000..e1bd0701f62 --- /dev/null +++ b/src/Threejs/composer.json @@ -0,0 +1,51 @@ +{ + "name": "symfony/ux-threejs", + "type": "symfony-bundle", + "description": "Three.js integration for Symfony", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Sylvain Blondeau", + "email": "contact@sylvainblondeau.dev" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Threejs\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Threejs\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.3", + "symfony/stimulus-bundle": "^2.18.1" + }, + "require-dev": { + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/ux-twig-component": "^2.18" + }, + "conflict": { + "symfony/ux-twig-component": "<2.21" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Threejs/doc/index.rst b/src/Threejs/doc/index.rst new file mode 100644 index 00000000000..a38eca446bd --- /dev/null +++ b/src/Threejs/doc/index.rst @@ -0,0 +1,150 @@ +Symfony UX Three Js +=================== + +**EXPERIMENTAL** This component is currently experimental and is likely +to change, or even change drastically. + +Symfony UX Three JS is a Symfony bundle integrating interactive [three.Js](https://threejs.org) library in Symfony applications. It is part of `the Symfony UX initiative`_. + +The package try to follow the structure of Three Js objects, unlike it not handle all the complexity of the javascript library. If you want more information about how to use three.js, you can start with this [manual page](https://threejs.org/manual) + +Installation +------------ + +Install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-threejs + +Create a Three JS scene +----------------------- + +First create a new instance by calling ``new Three()``. + +- A ThreeJs object is made of a renderer which contains a `Scene` and an array `Camera`. +- A `Scene` has a `Material`, an array of `Light`, an array of `Mesh` (geometrical objects) and an array of `Model` (loaded 3D models) + +Start by creating a new threejs instance:: + + use Symfony\UX\ThreeJs\Three; + + // Create a new threejs instance + $three = new Three(int $width, int $height); + +Default width and height is 300px. + +Add camera +~~~~~~~~~~ + +Threejs instance has a default camera but you can add new one with + + $three->addCamera(Camera $camera); + +You can several type of camera, like `PerspectiveCamera` or `OrthographicCamera` which extends abstract `Camera` class. + +Custom the scene +~~~~~~~~~~~~~~~~ + +A scene can have a background material (e.g color, texture). + + $three = new Three(); + $three->getScene()->setMaterial( + new MeshBasic('green'), + ); + + +Add a light to the scene +~~~~~~~~~~~~~~~~~~~~~~~~ + +Lights are related to `Scene` object. You can use the `Three::addLight(Light $light)` method to directly add a new light to the scene. + +You can add different type of lights (e.g. `AmbientLight`, `DirectionalLight`) which extends abstract `Light` class + + $three = new Three(); + $three->addLight( + new AmbientLight(color: 'blue', intensity: 3) + ); + + $three->addLight( + new DirectionalLight( + color: 'white', + intensity: 10, + position: new Vector3(x:1, y: 0, z: 1), + target: new Vector3(x: 1, y: 1, z: 0); + ) + ); + +`Vector3` is a generic class to manage x,y,z points. It is used in several classes. + +Add a Mesh to the scene +~~~~~~~~~~~~~~~~~~~~~~~ + +`Mesh` is a special 3D object which combines a geometrical shape with a material. + +- Several gometrical shape are available, with extends abstract `BufferGeometry` class, e.g : `Box`, `Sphere`, `Plane`, `Cylinder`... + +- Several Material are available (wich extends abstract `Material` class) e.g : `MeshBasic`, `MeshPhong`. Each type of material has a color or texture, and has specific surface properties (transparency, reflection...). + + $three = new Three(); + $three->addMesh( + new Mesh( + geometry: new Box(width: 1, height: 1, depth: 2), + material: new MeshBasic(color: 'green', opacity: 0.8), + ) + ); + + $three->addMesh( + new Mesh( + geometry: new Sphere(radius: 2), + material: new MeshPhong(map: 'path/to/texture.png'), + ) + ); + +Meshes have inherited methods `setAngle(Vector3 $angle)` and `setPosition(Vector3 $position)` to change mesh initial position and angle in the scene. + + $mesh->setPosition(x: 0, y: 0, z: 0); + $mesh->setAngle(aX: 0, aY: 0, aZ: 0); + +Animate a Mesh +~~~~~~~~~~~~~~ + +A mesh can be animated with translation, scale or rotation. `Mesh` object has an `animation` property which contains an `Animation` object. + + new Mesh( + geometry: new Box(width: 1, height: 1, depth: 2), + material: new MeshBasic(color: 'green', opacity: 0.8), + animation: (new Animation())->rotate(rY: 0.01)), + ); + +Load a 3D model +~~~~~~~~~~~~~~~ + +Even if you can create mesh and add them to a scene, you will mostly need to load existing complex 3D models. These models can be added to scene with function `Three::addModel(Model $model)`. Actual availabel model loader is `GLTFModel ` which extends abstract `Model` class/ + + $three = new Three(); + $three->addModel( + new GLTFModel( + path: '/path/to/model.glb' + ) + ); + +You can also animate a model with rotation, translation or scale like a `Mesh`, bug loaded models can also embbed their own complex animations (also called clip). Each animation has a name and you can play a model animation using + + new GLTFModel( + path: '/path/to/model.glb' + animation: new Animation( + playClip: 'clip_name', + ) + ) + +Render a three Js scene +----------------------- + +To render a map in your Twig template, use the ``render_threejs`` Twig function, which takes a `Three` object as parameter , e.g.: + +To be visible, the map must have a defined height: + +.. code-block:: twig + + {{ render_threejs(my_three_js }} diff --git a/src/Threejs/phpunit.xml.dist b/src/Threejs/phpunit.xml.dist new file mode 100644 index 00000000000..56e43f7c99d --- /dev/null +++ b/src/Threejs/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + ./src + + + + + + + + + + + tests + + + diff --git a/src/Threejs/src/Camera/Camera.php b/src/Threejs/src/Camera/Camera.php new file mode 100644 index 00000000000..4a15ee697da --- /dev/null +++ b/src/Threejs/src/Camera/Camera.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Camera; + +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + * + */ +abstract class Camera +{ + public Vector3 $position; + public string $type; + + public function __construct(?Vector3 $position = null) + { + $this->position = $position ?? new Vector3(0, 0, 5); + } + + public static function fromArray(array $camera): self + { + $camera['position'] = Vector3::fromArray($camera['position']); + $type = $camera['type']; + unset($camera['type']); + $cameraObject = new static(...$camera); + $cameraObject->type = $type; + + return $cameraObject; + } + + public function toArray(): array + { + return [ + 'type' => $this->type, + 'position' => $this->position->toArray(), + ]; + } +} diff --git a/src/Threejs/src/Camera/Orthographic.php b/src/Threejs/src/Camera/Orthographic.php new file mode 100644 index 00000000000..da65240ea67 --- /dev/null +++ b/src/Threejs/src/Camera/Orthographic.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Camera; + +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + */ +final class Orthographic extends Camera +{ + public string $type = 'Orthographic'; + + public function __construct( + public float $left = -1, + public float $right = 1, + public float $top = 1, + public float $bottom = -1, + public float $near = 0.1, + public float $far = 10, + ?Vector3 $position = null, + ) { + parent::__construct($position); + } +} diff --git a/src/Threejs/src/Camera/Perspective.php b/src/Threejs/src/Camera/Perspective.php new file mode 100644 index 00000000000..1886493f7b6 --- /dev/null +++ b/src/Threejs/src/Camera/Perspective.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Camera; + +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + */ +final class Perspective extends Camera +{ + public string $type = 'Perspective'; + + public function __construct( + public float $fov = 75, + public float $near = 0.1, + public float $far = 1000, + ?Vector3 $position = null, + ) { + parent::__construct($position); + } +} diff --git a/src/Threejs/src/DependencyInjection/ThreejsExtension.php b/src/Threejs/src/DependencyInjection/ThreejsExtension.php new file mode 100644 index 00000000000..55dc18e56f9 --- /dev/null +++ b/src/Threejs/src/DependencyInjection/ThreejsExtension.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\DependencyInjection; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\Threejs\Builder\ThreejsBuilder; +use Symfony\UX\Threejs\Builder\ThreejsBuilderInterface; +use Symfony\UX\Threejs\Twig\ThreejsExtension as ThreeJsTwigExtension; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +class ThreejsExtension extends Extension implements PrependExtensionInterface +{ + public function load(array $configs, ContainerBuilder $container) + { + $container + ->setDefinition('threejs.twig_extension', new Definition(ThreeJsTwigExtension::class)) + ->addArgument(new Reference('stimulus.helper')) + ->addTag('twig.extension') + ->setPublic(false) + ; + } + + public function prepend(ContainerBuilder $container) + { + if (!$this->isAssetMapperAvailable($container)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-threejs', + ], + ], + ]); + } + + private function isAssetMapperAvailable(ContainerBuilder $container): bool + { + if (!interface_exists(AssetMapperInterface::class)) { + return false; + } + + // check that FrameworkBundle 6.3 or higher is installed + $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); + if (!isset($bundlesMetadata['FrameworkBundle'])) { + return false; + } + + return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); + } +} diff --git a/src/Threejs/src/Geometry/Box.php b/src/Threejs/src/Geometry/Box.php new file mode 100644 index 00000000000..5eb1d55361f --- /dev/null +++ b/src/Threejs/src/Geometry/Box.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Geometry; + + +/** + * @author Sylvain Blondeau + * + */ +final class Box extends BufferGeometry +{ + public const string TYPE = 'Box'; + + public function __construct( + public float $width = 1, + public float $height = 1, + public float $depth = 1, + ) { + parent::__construct(); + $this->type = self::TYPE; + } + + + + public function toArray(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + 'depth' => $this->depth, + 'type' => $this->type, + ]; + } +} diff --git a/src/Threejs/src/Geometry/BufferGeometry.php b/src/Threejs/src/Geometry/BufferGeometry.php new file mode 100644 index 00000000000..d912be5bf8e --- /dev/null +++ b/src/Threejs/src/Geometry/BufferGeometry.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Geometry; +/** + * @author Sylvain Blondeau + * + */ +abstract class BufferGeometry +{ + public string $type; + + public function __construct( + ) + { + } + + public static function fromArray(array $geometry): self + { + $type = $geometry['type']; + unset($geometry['type']); + $geometryObject = new static(...$geometry); + $geometryObject->type = $type; + + return $geometryObject; + } + + abstract public function toArray(): array; + +} diff --git a/src/Threejs/src/Geometry/Cylinder.php b/src/Threejs/src/Geometry/Cylinder.php new file mode 100644 index 00000000000..0143919a0e2 --- /dev/null +++ b/src/Threejs/src/Geometry/Cylinder.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Geometry; + + +/** + * @author Sylvain Blondeau + * + */ +final class Cylinder extends BufferGeometry +{ + public const string TYPE = 'Cylinder'; + + public function __construct( + public float $radiusTop = 1, + public float $radiusBottom = 1, + public float $height = 1, + public int $radialSegments = 32, + public int $heightSegments = 1, + public bool $openEnded = false, + public float $thetaStart = 0, + public float $thetaLength = 2 * M_PI, + ) { + parent::__construct(); + $this->type = self::TYPE; + } + + public function toArray(): array + { + return [ + 'radiusTop' => $this->radiusTop, + 'radiusBottom' => $this->radiusBottom, + 'height' => $this->height, + 'radialSegments' => $this->radialSegments, + 'heightSegments' => $this->heightSegments, + 'openEnded' => $this->openEnded, + 'thetaStart' => $this->thetaStart, + 'thetaLength' => $this->thetaLength, + ]; + } +} + + diff --git a/src/Threejs/src/Geometry/Plane.php b/src/Threejs/src/Geometry/Plane.php new file mode 100644 index 00000000000..e616d6d6c37 --- /dev/null +++ b/src/Threejs/src/Geometry/Plane.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Geometry; + +/** + * @author Sylvain Blondeau + * + */ +final class Plane extends BufferGeometry +{ + public const string TYPE = 'Plane'; + + public function __construct( + public float $width = 1, + public float $height = 1, + public int $widthSegments = 1, + public int $heightSegments = 1, + ) { + parent::__construct(); + $this->type = self::TYPE; + } + + public function toArray(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + 'widthSegments' => $this->widthSegments, + 'heightSegments' => $this->heightSegments, + 'type' => $this->type, + ]; + } +} diff --git a/src/Threejs/src/Geometry/Sphere.php b/src/Threejs/src/Geometry/Sphere.php new file mode 100644 index 00000000000..7cabb3d6b35 --- /dev/null +++ b/src/Threejs/src/Geometry/Sphere.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Geometry; + +/** + * @author Sylvain Blondeau + * + */ +final class Sphere extends BufferGeometry +{ + public const string TYPE = 'Sphere'; + + public function __construct( + public float $radius = 1, + public int $widthSegments = 32, + public int $heightSegments = 16, + ) { + parent::__construct(); + $this->type = self::TYPE; + } + + public function toArray(): array + { + return [ + 'radius' => $this->radius, + 'widthSegments' => $this->widthSegments, + 'heightSegments' => $this->heightSegments, + 'type' => $this->type, + ]; + } +} diff --git a/src/Threejs/src/Light/Ambient.php b/src/Threejs/src/Light/Ambient.php new file mode 100644 index 00000000000..af8b026e51c --- /dev/null +++ b/src/Threejs/src/Light/Ambient.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Light; + +/** + * @author Sylvain Blondeau + */ +final class Ambient extends Light +{ + public const string TYPE = 'Ambient'; + + public string $type = self::TYPE; + +} \ No newline at end of file diff --git a/src/Threejs/src/Light/Directional.php b/src/Threejs/src/Light/Directional.php new file mode 100644 index 00000000000..cb0d9788d72 --- /dev/null +++ b/src/Threejs/src/Light/Directional.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Light; + +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + */ +final class Directional extends Light +{ + public const string TYPE = 'Directional'; + + public string $type = self::TYPE; + + public function __construct( + public string $color, + public float $intensity, + public Vector3 $position = new Vector3(), + public Vector3 $target = new Vector3(), + + ) { + parent::__construct($color, $intensity); + } + + public static function fromArray(array $light): self + { + $light['position'] = Vector3::fromArray($light['position']); + $light['target'] = Vector3::fromArray($light['target']); + $type = $light['type']; + unset($light['type']); + $lightObject = new static(...$light); + $lightObject->type = $type; + + return $lightObject; + } + + public function toArray(): array + { + return [ + 'type' => $this->type, + 'color' => $this->color, + 'intensity' => $this->intensity, + 'position' => $this->position->toArray(), + 'target' => $this->target->toArray(), + ]; + } +} \ No newline at end of file diff --git a/src/Threejs/src/Light/Light.php b/src/Threejs/src/Light/Light.php new file mode 100644 index 00000000000..f725c5f3f4d --- /dev/null +++ b/src/Threejs/src/Light/Light.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Light; + +/** + * @author Sylvain Blondeau + */ +abstract class Light +{ + public string $type; + + public function __construct( + public string $color = 'white', + public float $intensity = 1, + ) {} + + public static function fromArray(array $light): self + { + $type = $light['type']; + unset($light['type']); + $lightObject = new static(...$light); + $lightObject->type = $type; + + return $lightObject; + } + + public function toArray(): array + { + return [ + 'type' => $this->type, + 'color' => $this->color, + 'intensity' => $this->intensity, + ]; + } +} diff --git a/src/Threejs/src/Live/ComponentWithThreeTrait.php b/src/Threejs/src/Live/ComponentWithThreeTrait.php new file mode 100644 index 00000000000..a421d511c1e --- /dev/null +++ b/src/Threejs/src/Live/ComponentWithThreeTrait.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Live; + +use Symfony\UX\Threejs\Three; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\TwigComponent\Attribute\PostMount; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +trait ComponentWithThreeTrait +{ + /** + * @internal + */ + #[LiveProp(hydrateWith: 'hydrateThree', dehydrateWith: 'dehydrateThree')] + #[ExposeInTemplate(getter: 'getThree')] + public ?Three $three = null; + + abstract protected function instantiateThree(): Three; + + public function getThree(): Three + { + return $this->three ??= $this->instantiateThree(); + } + + /** + * @internal + */ + #[PostMount] + public function initializeThree(array $data): array + { + // allow the Three object to be passed into the component() as "three" + if (\array_key_exists('three', $data)) { + $this->three = $data['three']; + unset($data['three']); + } + + return $data; + } + + /** + * @internal + */ + public function hydrateThree(array $data): Three + { + return Three::fromArray($data); + } + + /** + * @internal + */ + public function dehydrateThree(Three $three): array + { + return $three->toArray(); + } +} diff --git a/src/Threejs/src/Material/Material.php b/src/Threejs/src/Material/Material.php new file mode 100644 index 00000000000..67d1a1597f9 --- /dev/null +++ b/src/Threejs/src/Material/Material.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Material; + +/** + * @author Sylvain Blondeau + * + */ +abstract class Material +{ + public string $type; + + public function __construct( + public ?string $color = null, + public float $opacity = 1, + public string $map = '', + public bool $doubleSide = false, + public bool $skybox = false, + public bool $transparent = false, + ) { + $this->transparent = $this->opacity < 1; + } + + public static function fromArray(array $material): self + { + $material['transparent'] = $material['opacity'] < 1; + $type = $material['type']; + unset($material['type']); + $materialObject = new static(...$material); + $materialObject->type = $type; + + return $materialObject; + } + + public function toArray(): array + { + return [ + 'transparent' => $this->transparent, + 'type' => $this->type, + 'color' => $this->color, + 'opacity' => $this->opacity, + 'map' => $this->map, + 'doubleSide' => $this->doubleSide, + 'skybox' => $this->skybox, + ]; + } +} diff --git a/src/Threejs/src/Material/MeshBasic.php b/src/Threejs/src/Material/MeshBasic.php new file mode 100644 index 00000000000..d103f52dc34 --- /dev/null +++ b/src/Threejs/src/Material/MeshBasic.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Material; + +/** + * @author Sylvain Blondeau + * + */ +final class MeshBasic extends Material +{ + public const string TYPE = 'MeshBasic'; + + public string $type = self::TYPE; +} diff --git a/src/Threejs/src/Material/MeshPhong.php b/src/Threejs/src/Material/MeshPhong.php new file mode 100644 index 00000000000..91b0d44f2f6 --- /dev/null +++ b/src/Threejs/src/Material/MeshPhong.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Material; + +/** + * @author Sylvain Blondeau + * + */ +final class MeshPhong extends Material +{ + public const string TYPE = 'MeshPhong'; + + public string $type = self::TYPE; + +} diff --git a/src/Threejs/src/Mesh.php b/src/Threejs/src/Mesh.php new file mode 100644 index 00000000000..f57c047f0a7 --- /dev/null +++ b/src/Threejs/src/Mesh.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs; + +use Symfony\UX\Threejs\Geometry\Box; +use Symfony\UX\Threejs\Utils\Animation; +use Symfony\UX\Threejs\Material\Material; +use Symfony\UX\Threejs\Material\MeshBasic; +use Symfony\UX\Threejs\Geometry\BufferGeometry; +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + * + * @final + */ +final class Mesh +{ + + public function __construct( + public ?BufferGeometry $geometry = new Box(), + public ?Material $material = new MeshBasic(), + public ?Animation $animation = new Animation(), + public Vector3 $position = new Vector3(), + public Vector3 $angle = new Vector3(), + ) {} + + public function setGeometry(BufferGeometry $geometry): self + { + $this->geometry = $geometry; + + return $this; + } + + public function setAnimation(Animation $animation): self + { + $this->animation = $animation; + + return $this; + } + + public function setMaterial(Material $material): self + { + $this->material = $material; + + return $this; + } + + public function setAngle(float $aX = 0, float $aY = 0, float $aZ = 0): self + { + $this->angle->x = $aX; + $this->angle->y = $aY; + $this->angle->z = $aZ; + + return $this; + } + + public function setPosition(float $x = 0, float $y = 0, float $z = 0): self + { + $this->position->x = $x; + $this->position->y = $y; + $this->position->z = $z; + + return $this; + } + + public static function fromArray(array $mesh): self + { + $mesh['material'] = ('Symfony\\UX\\Threejs\\Material\\'.$mesh['material']['type'])::fromArray($mesh['material']); + $mesh['geometry'] = ('Symfony\\UX\\Threejs\\Geometry\\'.$mesh['geometry']['type'])::fromArray($mesh['geometry']); + $mesh['animation'] = Animation::fromArray($mesh['animation']); + $mesh['position'] = Vector3::fromArray($mesh['position']); + $mesh['angle'] = Vector3::fromArray($mesh['angle']); + + return new static(...$mesh); + } + + public function toArray(): array + { + return [ + 'material' => $this->material->toArray(), + 'geometry' => $this->geometry->toArray(), + 'animation' => $this->animation->toArray(), + 'position' => $this->position->toArray(), + 'angle' => $this->angle->toArray(), + ]; + } +} diff --git a/src/Threejs/src/Model/GLTF.php b/src/Threejs/src/Model/GLTF.php new file mode 100644 index 00000000000..92a6ffcdbd9 --- /dev/null +++ b/src/Threejs/src/Model/GLTF.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Model; + + +/** + * @author Sylvain Blondeau + * + */ +final class GLTF extends Model +{ + public const string TYPE = 'GLTF'; + + public string $type = self::TYPE; + +} \ No newline at end of file diff --git a/src/Threejs/src/Model/Model.php b/src/Threejs/src/Model/Model.php new file mode 100644 index 00000000000..37983b87ca3 --- /dev/null +++ b/src/Threejs/src/Model/Model.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Model; + +use Symfony\UX\Threejs\Utils\Animation; +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + * + */ +abstract class Model +{ + public string $type; + + public function __construct( + public string $path, + public Vector3 $position = new Vector3(), + public Vector3 $angle = new Vector3(), + public Animation $animation = new Animation(), + ) { + } + + public static function fromArray(array $model): self + { + $model['position'] = Vector3::fromArray($model['position']); + $model['angle'] = Vector3::fromArray($model['angle']); + $model['animation'] = Animation::fromArray($model['animation']); + + $type = $model['type']; + unset($model['type']); + $modelObject = new static(...$model); + $modelObject->type = $type; + + return $modelObject; + } + + public function toArray(): array + { + return [ + 'path' => $this->path, + 'position' => $this->position, + 'angle' => $this->angle, + 'type' => $this->type, + 'animation' => $this->animation->toArray(), + ]; + } +} diff --git a/src/Threejs/src/Renderer.php b/src/Threejs/src/Renderer.php new file mode 100644 index 00000000000..6ae1dac8e94 --- /dev/null +++ b/src/Threejs/src/Renderer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs; + +use Symfony\UX\Threejs\Camera\Camera; +use Symfony\UX\Threejs\Camera\Perspective; + +/** + * @author Sylvain Blondeau + * + * @final + */ +final class Renderer +{ + public function __construct( + public Scene $scene = new Scene(), + public bool $controls = true, + public array $cameras = [new Perspective()], + public ?int $width = 300, + public ?int $height = 300, + ) {} + + public function setCameras(array $cameras): self + { + $this->cameras = $cameras; + + return $this; + } + + public function addCamera(Camera $camera): self + { + $this->cameras[] = $camera; + + return $this; + } + + public static function fromArray(array $renderer): self + { + $renderer['scene'] = Scene::fromArray($renderer['scene']); + $renderer['cameras'] = array_map((fn($camera) => ('Symfony\\UX\\Threejs\\Camera\\'.$camera['type'])::fromArray($camera)), $renderer['cameras']); + + return new self(...$renderer); + } + + public function toArray(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + 'controls' => $this->controls, + 'cameras' => array_map(fn($camera) => $camera->toArray(), $this->cameras), + 'scene' => $this->scene->toArray(), + ]; + } + +} diff --git a/src/Threejs/src/Scene.php b/src/Threejs/src/Scene.php new file mode 100644 index 00000000000..79b3b19c340 --- /dev/null +++ b/src/Threejs/src/Scene.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs; + +use Symfony\UX\Threejs\Light\Light; +use Symfony\UX\Threejs\Model\Model; +use Symfony\UX\Threejs\Material\Material; +use Symfony\UX\Threejs\Light\Ambient; +use Symfony\UX\Threejs\Material\MeshBasic; + +/** + * @author Sylvain Blondeau + * + * @final + */ +final class Scene +{ + public function __construct( + public ?Material $material = new MeshBasic(), + public array $lights = [new Ambient()], + public array $meshes = [], + public array $models = [], + ) + { + } + + public function setMaterial(Material $material): self + { + $this->material = $material; + + return $this; + } + + public function addLight(Light $light = new Ambient()): self + { + $this->lights[] = $light; + + return $this; + } + + public function addMesh(Mesh $mesh): self { + $this->meshes[] = $mesh; + + return $this; + } + + public function addModel(Model $model): self { + $this->models[] = $model; + + return $this; + } + + + public static function fromArray(array $scene): self + { + $scene['material'] = ('Symfony\\UX\\Threejs\\Material\\'.$scene['material']['type'])::fromArray($scene['material']) ; + $scene['lights'] = array_map(fn($light) => ('Symfony\\UX\\Threejs\\Light\\'.$light['type'])::fromArray($light), $scene['lights']); + $scene['meshes'] = array_map(fn($mesh) => Mesh::fromArray($mesh), $scene['meshes']); + $scene['models'] = array_map(fn($model) => ('Symfony\\UX\\Threejs\\Model\\'.$model['type'])::fromArray($model), $scene['models']); + + return new self(...$scene); + } + + public function toArray(): array + { + return [ + 'material' => $this->material->toArray(), + 'lights' => array_map(fn($light) => $light->toArray(), $this->lights), + 'meshes' => array_map(fn($mesh) => $mesh->toArray(), $this->meshes), + 'models' => array_map(fn($model) => $model->toArray(), $this->models), + ]; + } +} diff --git a/src/Threejs/src/Three.php b/src/Threejs/src/Three.php new file mode 100644 index 00000000000..44bc6b6acff --- /dev/null +++ b/src/Threejs/src/Three.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs; + +use Symfony\UX\Threejs\Mesh; +use Symfony\UX\Threejs\Light\Light; +use Symfony\UX\Threejs\Model\Model; +use Symfony\UX\Threejs\Camera\Camera; + +/** + * @author Sylvain Blondeau + * + * @final + */ +class Three +{ + + public function __construct( + public int $width = 300, + public int $height = 300, + public Renderer $renderer = new Renderer(), + ) { + $this->renderer->scene ??= new Scene(); + $this->renderer->width ??= $this->width; + $this->renderer->height ??= $this->height; + + } + + public function addCamera(Camera $camera): self { + $this->renderer->addCamera($camera); + + return $this; + } + + public function addLight(Light $light): self { + $this->getScene()->addLight($light); + + return $this; + } + + public function addMesh(Mesh $mesh): self { + $this->renderer->scene->addMesh( + $mesh, + ); + + return $this; + } + + public function addModel(Model $model): self + { + $this->renderer->scene->addModel($model); + + return $this; + } + + public function getModels() + { + return $this->renderer->scene->models; + } + + public function getScene(): Scene + { + return $this->renderer->scene; + } + + public function setScene(Scene $scene): self { + $this->renderer->scene = clone $scene; + + return $this; + } + + public function createThree(): Three + { + return $this; + } + + public static function fromArray(array $three): self + { + $three['renderer'] = Renderer::fromArray($three['renderer']); + + return new self(...$three); + } + + public function toArray(): array + { + + return [ + 'width' => $this->width, + 'height' => $this->height, + 'renderer' => $this->renderer->toArray(), + ]; + } + +} diff --git a/src/Threejs/src/ThreejsBundle.php b/src/Threejs/src/ThreejsBundle.php new file mode 100644 index 00000000000..1f513c9b425 --- /dev/null +++ b/src/Threejs/src/ThreejsBundle.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Sylvain Blondeau + * + */ +final class ThreejsBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/Threejs/src/Twig/ThreejsExtension.php b/src/Threejs/src/Twig/ThreejsExtension.php new file mode 100644 index 00000000000..9c9ab6b0f48 --- /dev/null +++ b/src/Threejs/src/Twig/ThreejsExtension.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Twig; + +use Twig\TwigFunction; +use Symfony\UX\Threejs\Three; +use Twig\Extension\AbstractExtension; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; + +/** + * @author Sylvain Blondeau + * + */ +final class ThreejsExtension extends AbstractExtension +{ + private $stimulus; + + /** + * @param $stimulus StimulusHelper + */ + public function __construct(StimulusHelper $stimulus) + { + $this->stimulus = $stimulus; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('render_threejs', [$this, 'renderThreejs'], ['is_safe' => ['html']]), + ]; + } + + public function renderThreejs(Three $threejs): string + { + $controllers = []; + + $controllers['@symfony/ux-threejs/three'] = ['three' => $threejs->createThree()]; + + $stimulusAttributes = $this->stimulus->createStimulusAttributes(); + foreach ($controllers as $name => $controllerValues) { + $stimulusAttributes->addController($name, $controllerValues); + } + + return \sprintf('
', $stimulusAttributes); + } +} diff --git a/src/Threejs/src/Utils/Animation.php b/src/Threejs/src/Utils/Animation.php new file mode 100644 index 00000000000..36fdd8873e5 --- /dev/null +++ b/src/Threejs/src/Utils/Animation.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Utils; + +/** + * @author Sylvain Blondeau + * + */ +final class Animation +{ + public Vector3 $rotation; + public Vector3 $translation; + public Vector3 $scale; + + public function __construct( + public ?string $playClip = null, + ) { + $this->rotation = new Vector3(); + $this->translation = new Vector3(); + $this->scale = new Vector3(); + } + + public function rotate(float $rX = 0, float $rY = 0, float $rZ = 0): self + { + $this->rotation->x += $rX; + $this->rotation->y += $rY; + $this->rotation->z += $rZ; + + return $this; + } + + public function translate(float $tX = 0, float $tY = 0, float $tZ = 0): self { + $this->translation->x += $tX; + $this->translation->y += $tY; + $this->translation->z += $tZ; + + return $this; + } + + public function scale(float $sX = 0, float $sY = 0, float $sZ = 0): self { + $this->scale->x += $sX; + $this->scale->y += $sY; + $this->scale->z += $sZ; + + return $this; + } + + public static function fromArray(array $animation): self + { + $animation['rotation'] = Vector3::fromArray($animation['rotation']); + $animation['translation'] = Vector3::fromArray($animation['translation']); + $animation['scale'] = Vector3::fromArray($animation['scale']); + $animationObject = new self($animation['playClip']); + $animationObject->rotate($animation['rotation']->x, $animation['rotation']->y, $animation['rotation']->z); + $animationObject->translate($animation['translation']->x, $animation['translation']->y, $animation['translation']->z); + $animationObject->scale($animation['scale']->x, $animation['scale']->y, $animation['scale']->z); + + return $animationObject; + } + + public function toArray(): array + { + return [ + 'playClip' => $this->playClip, + 'rotation' => $this->rotation->toArray(), + 'translation' => $this->translation->toArray(), + 'scale' => $this->scale->toArray(), + ]; + } +} diff --git a/src/Threejs/src/Utils/Vector3.php b/src/Threejs/src/Utils/Vector3.php new file mode 100644 index 00000000000..0e66cb4c66e --- /dev/null +++ b/src/Threejs/src/Utils/Vector3.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Utils; + +/** + * @author Sylvain Blondeau + * + */ +final class Vector3 +{ + public function __construct( + public float $x = 0, + public float $y = 0, + public float $z = 0, + ) {} + + + public static function fromArray(array $vector3): self + { + return new self(...$vector3); + } + + public function toArray(): array + { + return [ + 'x' => $this->x, + 'y' => $this->y, + 'z' => $this->z, + ]; + } +} diff --git a/src/Threejs/tests/Kernel/TwigAppKernel.php b/src/Threejs/tests/Kernel/TwigAppKernel.php new file mode 100644 index 00000000000..daecccc04d9 --- /dev/null +++ b/src/Threejs/tests/Kernel/TwigAppKernel.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Threejs\ThreejsBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +class TwigAppKernel extends Kernel +{ + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new TwigBundle(), new StimulusBundle(), new ThreeJsBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); + $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); + + $container->setAlias('test.threejs.twig_extension', 'threejs.twig_extension')->setPublic(true); + }); + } +} diff --git a/src/Threejs/tests/ThreejsBundleTest.php b/src/Threejs/tests/ThreejsBundleTest.php new file mode 100644 index 00000000000..189cafd5277 --- /dev/null +++ b/src/Threejs/tests/ThreejsBundleTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Threejs\Tests\Kernel\TwigAppKernel; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +class ThreejsBundleTest extends TestCase +{ + public function testBootKernel() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + $this->assertArrayHasKey('ThreejsBundle', $kernel->getBundles()); + } +} diff --git a/src/Threejs/tests/Twig/ThreeExtensionTest.php b/src/Threejs/tests/Twig/ThreeExtensionTest.php new file mode 100644 index 00000000000..3251921b975 --- /dev/null +++ b/src/Threejs/tests/Twig/ThreeExtensionTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Threejs\Tests; + +use Symfony\UX\Threejs\Mesh; +use Symfony\UX\Threejs\Three; +use PHPUnit\Framework\TestCase; +use Symfony\UX\Threejs\Geometry\Box; +use Symfony\UX\Threejs\Material\MeshBasic; +use Symfony\UX\Threejs\Tests\Kernel\TwigAppKernel; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +class ThreeExtensionTest extends TestCase +{ + public function testRenderThree() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + $container = $kernel->getContainer()->get('test.service_container'); + + $three = new Three(500, 500); + + $three->addMesh( + new Mesh( + geometry: new Box(1, 2, 3), + material: new MeshBasic('green'), + ) + ); + + $rendered = $container->get('test.threejs.twig_extension')->renderThreejs( + $three, + ['data-controller' => 'mycontroller', 'class' => 'myclass'] + ); + + $this->assertSame( + '
', + $rendered + ); + } +} diff --git a/yarn.lock b/yarn.lock index 3fe81d3cf16..3466acbe330 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3013,6 +3013,21 @@ __metadata: languageName: unknown linkType: soft +"@symfony/ux-threejs@workspace:src/Threejs/assets": + version: 0.0.0-use.local + resolution: "@symfony/ux-threejs@workspace:src/Threejs/assets" + dependencies: + "@hotwired/stimulus": "npm:^3.0.0" + happy-dom: "npm:^17.4.4" + resize-observer-polyfill: "npm:^1.5.1" + three: "npm:^0.174.0" + vitest-canvas-mock: "npm:^0.3.3" + peerDependencies: + "@hotwired/stimulus": ^3.0.0 + three: ^0.174.0 + languageName: unknown + linkType: soft + "@symfony/ux-toggle-password@workspace:src/TogglePassword/assets": version: 0.0.0-use.local resolution: "@symfony/ux-toggle-password@workspace:src/TogglePassword/assets" @@ -3162,6 +3177,13 @@ __metadata: languageName: node linkType: hard +"@tweenjs/tween.js@npm:~23.1.3": + version: 23.1.3 + resolution: "@tweenjs/tween.js@npm:23.1.3" + checksum: 10c0/811b30f5f0e7409fb41833401c501c2d6f600eb5f43039dd9067a7f70aff6dad5f5ce1233848e13f0b33a269a160d9c133f344d986cbff4f1f6b72ddecd06c89 + languageName: node + linkType: hard + "@types/aria-query@npm:^4.2.0": version: 4.2.2 resolution: "@types/aria-query@npm:4.2.2" @@ -3429,6 +3451,13 @@ __metadata: languageName: node linkType: hard +"@types/stats.js@npm:*": + version: 0.17.3 + resolution: "@types/stats.js@npm:0.17.3" + checksum: 10c0/ccccc992c6dfe08fb85049aa3ce44ca7e428db8da4a3edd20298f1c8b72768021fa8bacdfbe8e9735a7552ee5d57f667c6f557050ad2d9a87b699b3566a6177a + languageName: node + linkType: hard + "@types/statuses@npm:^2.0.4": version: 2.0.5 resolution: "@types/statuses@npm:2.0.5" @@ -3445,6 +3474,20 @@ __metadata: languageName: node linkType: hard +"@types/three@npm:^0.174.0": + version: 0.174.0 + resolution: "@types/three@npm:0.174.0" + dependencies: + "@tweenjs/tween.js": "npm:~23.1.3" + "@types/stats.js": "npm:*" + "@types/webxr": "npm:*" + "@webgpu/types": "npm:*" + fflate: "npm:~0.8.2" + meshoptimizer: "npm:~0.18.1" + checksum: 10c0/338e9d8d01d373014ee3c3b686a0d633c1a29603fe199c7c5340fc1a35c17e6eea0d0a7bba5ff9a33b8e462e5b471c0829c2aaa34ab2300e8d6e7e3f885e45b5 + languageName: node + linkType: hard + "@types/tough-cookie@npm:^4.0.5": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" @@ -3459,6 +3502,13 @@ __metadata: languageName: node linkType: hard +"@types/webxr@npm:*": + version: 0.5.21 + resolution: "@types/webxr@npm:0.5.21" + checksum: 10c0/7b6a7001f8592a0c8f1bff46352a451a5bcc24d016d10985a4d7bff9b778c4530fb82e48db1a7e1da67ff4f75bff49384fdac5e56fa103455f8281c3c0f403a6 + languageName: node + linkType: hard + "@types/wrap-ansi@npm:^3.0.0": version: 3.0.0 resolution: "@types/wrap-ansi@npm:3.0.0" @@ -3738,6 +3788,13 @@ __metadata: languageName: node linkType: hard +"@webgpu/types@npm:*": + version: 0.1.56 + resolution: "@webgpu/types@npm:0.1.56" + checksum: 10c0/b3ff79042a50c02863cadef4cb445b02e5c53e622d3ed6070d6b38af4a1e7e6bae1e481c8fa955f2682e6328d705cf60b6ea90ef17fb65d0e4a6a9657eab5653 + languageName: node + linkType: hard + "abab@npm:^2.0.3, abab@npm:^2.0.5": version: 2.0.6 resolution: "abab@npm:2.0.6" @@ -5382,6 +5439,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:~0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + "fill-range@npm:^4.0.0": version: 4.0.0 resolution: "fill-range@npm:4.0.0" @@ -5667,6 +5731,16 @@ __metadata: languageName: node linkType: hard +"happy-dom@npm:^17.4.4": + version: 17.4.4 + resolution: "happy-dom@npm:17.4.4" + dependencies: + webidl-conversions: "npm:^7.0.0" + whatwg-mimetype: "npm:^3.0.0" + checksum: 10c0/a84d36fc633ab5e5a36ae55d82b8955d1e65394242b4dba551ab447579e47f8565de61c361920c248ed2823883715dfa513ce46666800ad53c21dbde858048c2 + languageName: node + linkType: hard + "has-bigints@npm:^1.0.1": version: 1.0.2 resolution: "has-bigints@npm:1.0.2" @@ -7438,6 +7512,13 @@ __metadata: languageName: node linkType: hard +"meshoptimizer@npm:~0.18.1": + version: 0.18.1 + resolution: "meshoptimizer@npm:0.18.1" + checksum: 10c0/8a825c58b20b65585e8d00788843929b60c66ba4297e89afaa49f7c51ab9a0f7b9130f90cc9ad1b9b48b3d1bee3beb1bc93608acba0d73e78995c3e6e5ca436b + languageName: node + linkType: hard + "microbundle@link:node_modules/.cache/null::locator=root-workspace-0b6124%40workspace%3A.": version: 0.0.0-use.local resolution: "microbundle@link:node_modules/.cache/null::locator=root-workspace-0b6124%40workspace%3A." @@ -8802,6 +8883,7 @@ __metadata: "@rollup/plugin-node-resolve": "npm:^15.3.0" "@rollup/plugin-typescript": "npm:^11.1.6" "@symfony/stimulus-testing": "npm:^2.0.1" + "@types/three": "npm:^0.174.0" "@vitest/browser": "npm:^2.1.1" lightningcss: "npm:^1.28.2" playwright: "npm:^1.47.0" @@ -9489,6 +9571,13 @@ __metadata: languageName: node linkType: hard +"three@npm:^0.174.0": + version: 0.174.0 + resolution: "three@npm:0.174.0" + checksum: 10c0/704c3aaa72a7e97ad4f829e81870c8add856ab5a8fb4fc34e391033c527df5cb87a283edb88e69d2d6974bee8afe732c616e879081c8ea232103aa806b97793b + languageName: node + linkType: hard + "throat@npm:^5.0.0": version: 5.0.0 resolution: "throat@npm:5.0.0" @@ -10097,6 +10186,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + "whatwg-encoding@npm:^1.0.5": version: 1.0.5 resolution: "whatwg-encoding@npm:1.0.5" @@ -10113,6 +10209,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0"