From 23d855e0738d17ad4f2054d34a10745fd36a23af Mon Sep 17 00:00:00 2001 From: Sylvain Blondeau Date: Sat, 22 Mar 2025 23:22:14 +0100 Subject: [PATCH 1/4] add an ux-threejs package --- package.json | 1 + src/Threejs/.gitattributes | 8 + src/Threejs/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 ++ src/Threejs/.gitignore | 6 + src/Threejs/.symfony.bundle.yaml | 3 + src/Threejs/CHANGELOG.md | 5 + src/Threejs/LICENSE | 19 ++ src/Threejs/README.md | 30 ++ src/Threejs/assets/LICENSE | 19 ++ src/Threejs/assets/README.md | 22 ++ src/Threejs/assets/dist/controller.d.ts | 50 ++++ src/Threejs/assets/dist/controller.js | 189 ++++++++++++ src/Threejs/assets/package.json | 49 ++++ src/Threejs/assets/src/controller.ts | 270 ++++++++++++++++++ src/Threejs/assets/test/controller.test.ts | 65 +++++ src/Threejs/assets/test/setup.js | 12 + src/Threejs/assets/vitest.config.mjs | 20 ++ src/Threejs/composer.json | 53 ++++ src/Threejs/doc/index.rst | 150 ++++++++++ src/Threejs/phpunit.xml.dist | 26 ++ src/Threejs/src/Camera/Camera.php | 28 ++ src/Threejs/src/Camera/OrthographicCamera.php | 34 +++ src/Threejs/src/Camera/PerspectiveCamera.php | 31 ++ .../DependencyInjection/ThreejsExtension.php | 70 +++++ src/Threejs/src/Geometry/Box.php | 31 ++ src/Threejs/src/Geometry/BufferGeometry.php | 27 ++ src/Threejs/src/Geometry/Cylinder.php | 38 +++ src/Threejs/src/Geometry/Plane.php | 31 ++ src/Threejs/src/Geometry/Sphere.php | 30 ++ src/Threejs/src/Light/AmbientLight.php | 23 ++ src/Threejs/src/Light/DirectionalLight.php | 34 +++ src/Threejs/src/Light/Light.php | 25 ++ src/Threejs/src/Material/Material.php | 34 +++ src/Threejs/src/Material/MeshBasic.php | 24 ++ src/Threejs/src/Material/MeshPhong.php | 22 ++ src/Threejs/src/Mesh.php | 73 +++++ src/Threejs/src/Model/GLTFModel.php | 25 ++ src/Threejs/src/Model/Model.php | 32 +++ src/Threejs/src/Renderer.php | 45 +++ src/Threejs/src/Scene.php | 61 ++++ src/Threejs/src/Three.php | 86 ++++++ src/Threejs/src/ThreejsBundle.php | 26 ++ src/Threejs/src/Twig/ThreejsExtension.php | 55 ++++ src/Threejs/src/Utils/Animation.php | 56 ++++ src/Threejs/src/Utils/Vector3.php | 25 ++ src/Threejs/tests/Kernel/TwigAppKernel.php | 43 +++ src/Threejs/tests/ThreejsBundleTest.php | 30 ++ src/Threejs/tests/Twig/ThreeExtensionTest.php | 53 ++++ yarn.lock | 103 +++++++ 50 files changed, 2220 insertions(+) create mode 100644 src/Threejs/.gitattributes create mode 100644 src/Threejs/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/Threejs/.github/workflows/close-pull-request.yml create mode 100644 src/Threejs/.gitignore create mode 100644 src/Threejs/.symfony.bundle.yaml create mode 100644 src/Threejs/CHANGELOG.md create mode 100644 src/Threejs/LICENSE create mode 100644 src/Threejs/README.md create mode 100644 src/Threejs/assets/LICENSE create mode 100644 src/Threejs/assets/README.md create mode 100644 src/Threejs/assets/dist/controller.d.ts create mode 100644 src/Threejs/assets/dist/controller.js create mode 100644 src/Threejs/assets/package.json create mode 100644 src/Threejs/assets/src/controller.ts create mode 100644 src/Threejs/assets/test/controller.test.ts create mode 100644 src/Threejs/assets/test/setup.js create mode 100644 src/Threejs/assets/vitest.config.mjs create mode 100644 src/Threejs/composer.json create mode 100644 src/Threejs/doc/index.rst create mode 100644 src/Threejs/phpunit.xml.dist create mode 100644 src/Threejs/src/Camera/Camera.php create mode 100644 src/Threejs/src/Camera/OrthographicCamera.php create mode 100644 src/Threejs/src/Camera/PerspectiveCamera.php create mode 100644 src/Threejs/src/DependencyInjection/ThreejsExtension.php create mode 100644 src/Threejs/src/Geometry/Box.php create mode 100644 src/Threejs/src/Geometry/BufferGeometry.php create mode 100644 src/Threejs/src/Geometry/Cylinder.php create mode 100644 src/Threejs/src/Geometry/Plane.php create mode 100644 src/Threejs/src/Geometry/Sphere.php create mode 100644 src/Threejs/src/Light/AmbientLight.php create mode 100644 src/Threejs/src/Light/DirectionalLight.php create mode 100644 src/Threejs/src/Light/Light.php create mode 100644 src/Threejs/src/Material/Material.php create mode 100644 src/Threejs/src/Material/MeshBasic.php create mode 100644 src/Threejs/src/Material/MeshPhong.php create mode 100644 src/Threejs/src/Mesh.php create mode 100644 src/Threejs/src/Model/GLTFModel.php create mode 100644 src/Threejs/src/Model/Model.php create mode 100644 src/Threejs/src/Renderer.php create mode 100644 src/Threejs/src/Scene.php create mode 100644 src/Threejs/src/Three.php create mode 100644 src/Threejs/src/ThreejsBundle.php create mode 100644 src/Threejs/src/Twig/ThreejsExtension.php create mode 100644 src/Threejs/src/Utils/Animation.php create mode 100644 src/Threejs/src/Utils/Vector3.php create mode 100644 src/Threejs/tests/Kernel/TwigAppKernel.php create mode 100644 src/Threejs/tests/ThreejsBundleTest.php create mode 100644 src/Threejs/tests/Twig/ThreeExtensionTest.php 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..8e3e79db213 --- /dev/null +++ b/src/Threejs/assets/dist/controller.d.ts @@ -0,0 +1,50 @@ +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; +}; +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; + }; + connect(): 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; +} diff --git a/src/Threejs/assets/dist/controller.js b/src/Threejs/assets/dist/controller.js new file mode 100644 index 00000000000..58fd964ea4f --- /dev/null +++ b/src/Threejs/assets/dist/controller.js @@ -0,0 +1,189 @@ +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 { + connect() { + this.dispatchEvent('pre-connect', { + options: this.threeValue, + }); + const renderer = new THREE.WebGLRenderer(); + const rendererValue = this.threeValue.renderer; + renderer.setSize(rendererValue.width ?? window.innerWidth, rendererValue.height ?? window.innerHeight); + this.element.appendChild(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) { + scene.background = new THREE.TextureLoader().load(sceneValue.material.map); + } + const cameras = []; + for (let cameraData of rendererValue.cameras) { + cameras.push(this.createCamera(cameraData, renderer)); + } + for (let lightData of sceneValue.lights) { + this.createLight(lightData, scene); + } + if (rendererValue.controls) { + this.setControls(cameras[0], renderer); + } + for (let modelData of this.threeValue.renderer.scene.models) { + console.log(modelData); + 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; + } + renderer.render(scene, cameras[0]); + requestAnimationFrame(animate); + }; + animate(); + this.dispatchEvent('connect', { + renderer: 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); + 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' }); + } +} +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..d4f74592c09 --- /dev/null +++ b/src/Threejs/assets/src/controller.ts @@ -0,0 +1,270 @@ +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; +} + +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, + } + + connect() { + this.dispatchEvent('pre-connect', { + options: this.threeValue, + }); + + /** init renderer */ + const renderer = new THREE.WebGLRenderer(); + const rendererValue = this.threeValue.renderer; + renderer.setSize(rendererValue.width ?? window.innerWidth, rendererValue.height ?? window.innerHeight); + this.element.appendChild(renderer.domElement); + // /** init scene */ + const sceneValue = rendererValue.scene; + let scene = new THREE.Scene(); + const light = new THREE.AmbientLight(0x404040); // Lumière ambiante + scene.add(light); + if (sceneValue.material.color) { + scene.background = new THREE.Color(sceneValue.material.color); + } + if (sceneValue.material.map) { + scene.background = new THREE.TextureLoader().load(sceneValue.material.map); + } + + /** cameras */ + const cameras: THREE.Camera[] = []; + for (let cameraData of rendererValue.cameras) { + cameras.push(this.createCamera(cameraData, renderer)); + } + + /** lights */ + for (let lightData of sceneValue.lights) { + this.createLight(lightData, scene); + } + + /** controls */ + if (rendererValue.controls) { + this.setControls(cameras[0], renderer); + } + + /** load 3d models */ + for (let modelData of this.threeValue.renderer.scene.models) { + console.log(modelData); + 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; + } + renderer.render(scene, cameras[0]); + + requestAnimationFrame(animate); + }; + + animate(); + + this.dispatchEvent('connect', { + renderer: 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); + + 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' }); + } +} + 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..88978a87bc9 --- /dev/null +++ b/src/Threejs/composer.json @@ -0,0 +1,53 @@ +{ + "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.1", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/stimulus-bundle": "^2.9.1" + }, + "require-dev": { + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/flex": "<1.13" + }, + "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..df253557d79 --- /dev/null +++ b/src/Threejs/src/Camera/Camera.php @@ -0,0 +1,28 @@ + + * + * 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 function __construct(?Vector3 $position = null) + { + $this->position = $position ?? new Vector3(0, 0, 5); + } +} diff --git a/src/Threejs/src/Camera/OrthographicCamera.php b/src/Threejs/src/Camera/OrthographicCamera.php new file mode 100644 index 00000000000..37d333b426e --- /dev/null +++ b/src/Threejs/src/Camera/OrthographicCamera.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 OrthographicCamera 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/PerspectiveCamera.php b/src/Threejs/src/Camera/PerspectiveCamera.php new file mode 100644 index 00000000000..5b89571d898 --- /dev/null +++ b/src/Threejs/src/Camera/PerspectiveCamera.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 PerspectiveCamera 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..5104e195933 --- /dev/null +++ b/src/Threejs/src/Geometry/Box.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\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; + } +} diff --git a/src/Threejs/src/Geometry/BufferGeometry.php b/src/Threejs/src/Geometry/BufferGeometry.php new file mode 100644 index 00000000000..92f56c36349 --- /dev/null +++ b/src/Threejs/src/Geometry/BufferGeometry.php @@ -0,0 +1,27 @@ + + * + * 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( + ) + { + } + + +} diff --git a/src/Threejs/src/Geometry/Cylinder.php b/src/Threejs/src/Geometry/Cylinder.php new file mode 100644 index 00000000000..dccb40a5ba9 --- /dev/null +++ b/src/Threejs/src/Geometry/Cylinder.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 + * + */ +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; + } +} + + diff --git a/src/Threejs/src/Geometry/Plane.php b/src/Threejs/src/Geometry/Plane.php new file mode 100644 index 00000000000..cad54ce2d85 --- /dev/null +++ b/src/Threejs/src/Geometry/Plane.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\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; + } +} diff --git a/src/Threejs/src/Geometry/Sphere.php b/src/Threejs/src/Geometry/Sphere.php new file mode 100644 index 00000000000..e9b5b468aa4 --- /dev/null +++ b/src/Threejs/src/Geometry/Sphere.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\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; + } +} diff --git a/src/Threejs/src/Light/AmbientLight.php b/src/Threejs/src/Light/AmbientLight.php new file mode 100644 index 00000000000..f6584cfe824 --- /dev/null +++ b/src/Threejs/src/Light/AmbientLight.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 AmbientLight 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/DirectionalLight.php b/src/Threejs/src/Light/DirectionalLight.php new file mode 100644 index 00000000000..a2a688c1b33 --- /dev/null +++ b/src/Threejs/src/Light/DirectionalLight.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\Light; + +use Symfony\UX\Threejs\Utils\Vector3; + +/** + * @author Sylvain Blondeau + */ +final class DirectionalLight 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); + } +} \ 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..ff816e61140 --- /dev/null +++ b/src/Threejs/src/Light/Light.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\Light; + +/** + * @author Sylvain Blondeau + */ +abstract class Light +{ + public string $type; + + public function __construct( + public string $color = 'white', + public float $intensity = 1, + ) {} +} diff --git a/src/Threejs/src/Material/Material.php b/src/Threejs/src/Material/Material.php new file mode 100644 index 00000000000..49596131cf3 --- /dev/null +++ b/src/Threejs/src/Material/Material.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\Material; + +/** + * @author Sylvain Blondeau + * + */ +abstract class Material +{ + public bool $transparent; + public string $type; + + public function __construct( + public ?string $color = null, + public float $opacity = 1, + public string $map = '', + public bool $doubleSide = false, + + ) { + $this->transparent = $this->opacity < 1; + } + + +} diff --git a/src/Threejs/src/Material/MeshBasic.php b/src/Threejs/src/Material/MeshBasic.php new file mode 100644 index 00000000000..ff7217d77b0 --- /dev/null +++ b/src/Threejs/src/Material/MeshBasic.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 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..ab345432e46 --- /dev/null +++ b/src/Threejs/src/Material/MeshPhong.php @@ -0,0 +1,22 @@ + + * + * 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..2abe84b8745 --- /dev/null +++ b/src/Threejs/src/Mesh.php @@ -0,0 +1,73 @@ + + * + * 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; + } +} diff --git a/src/Threejs/src/Model/GLTFModel.php b/src/Threejs/src/Model/GLTFModel.php new file mode 100644 index 00000000000..0e70969f56c --- /dev/null +++ b/src/Threejs/src/Model/GLTFModel.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 GLTFModel 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..b38d012d530 --- /dev/null +++ b/src/Threejs/src/Model/Model.php @@ -0,0 +1,32 @@ + + * + * 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(), + ) { + } +} diff --git a/src/Threejs/src/Renderer.php b/src/Threejs/src/Renderer.php new file mode 100644 index 00000000000..32c551e5783 --- /dev/null +++ b/src/Threejs/src/Renderer.php @@ -0,0 +1,45 @@ + + * + * 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\PerspectiveCamera; + +/** + * @author Sylvain Blondeau + * + * @final + */ +final class Renderer +{ + public function __construct( + public Scene $scene = new Scene(), + public bool $controls = true, + public array $cameras = [new PerspectiveCamera()], + 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; + } +} diff --git a/src/Threejs/src/Scene.php b/src/Threejs/src/Scene.php new file mode 100644 index 00000000000..4b8f850d6f5 --- /dev/null +++ b/src/Threejs/src/Scene.php @@ -0,0 +1,61 @@ + + * + * 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\AmbientLight; +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 AmbientLight()], + public array $meshes = [], + public array $models = [], + ) + { + } + + public function setMaterial(Material $material): self + { + $this->material = $material; + + return $this; + } + + public function addLight(Light $light = new AmbientLight()): 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; + } +} diff --git a/src/Threejs/src/Three.php b/src/Threejs/src/Three.php new file mode 100644 index 00000000000..54a7b6fcea4 --- /dev/null +++ b/src/Threejs/src/Three.php @@ -0,0 +1,86 @@ + + * + * 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 Renderer $renderer; + + public function __construct( + private int $width = 300, + private int $height = 300, + ) { + $this->renderer = new Renderer( + scene: new Scene(), + width: $this->width, + 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; + } +} 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..549627cd669 --- /dev/null +++ b/src/Threejs/src/Utils/Animation.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\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; + } +} diff --git a/src/Threejs/src/Utils/Vector3.php b/src/Threejs/src/Utils/Vector3.php new file mode 100644 index 00000000000..d6fc9d3aa85 --- /dev/null +++ b/src/Threejs/src/Utils/Vector3.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\Utils; + +/** + * @author Sylvain Blondeau + * + */ +final class Vector3 +{ + public function __construct( + public float $x = 0, + public float $y = 0, + public float $z = 0, + ) {} +} 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" From 4f4b3b72d69b4cebeec0a449da514968f6acbd67 Mon Sep 17 00:00:00 2001 From: Sylvain Blondeau Date: Mon, 31 Mar 2025 22:37:12 +0200 Subject: [PATCH 2/4] add skybox opiton to background texture --- src/Threejs/assets/dist/controller.d.ts | 1 + src/Threejs/assets/dist/controller.js | 5 ++++- src/Threejs/assets/src/controller.ts | 11 +++++++++-- src/Threejs/composer.json | 18 ++++++++---------- src/Threejs/src/Material/Material.php | 1 + 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/Threejs/assets/dist/controller.d.ts b/src/Threejs/assets/dist/controller.d.ts index 8e3e79db213..391a9b710c4 100644 --- a/src/Threejs/assets/dist/controller.d.ts +++ b/src/Threejs/assets/dist/controller.d.ts @@ -7,6 +7,7 @@ export type Material = { transparent: boolean; type: string; doubleSide: boolean; + skybox: boolean; }; export type Mesh = { geometry: any; diff --git a/src/Threejs/assets/dist/controller.js b/src/Threejs/assets/dist/controller.js index 58fd964ea4f..4fce2701065 100644 --- a/src/Threejs/assets/dist/controller.js +++ b/src/Threejs/assets/dist/controller.js @@ -20,7 +20,10 @@ class default_1 extends Controller { scene.background = new THREE.Color(sceneValue.material.color); } if (sceneValue.material.map) { - scene.background = new THREE.TextureLoader().load(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) { diff --git a/src/Threejs/assets/src/controller.ts b/src/Threejs/assets/src/controller.ts index d4f74592c09..7abe29b89c2 100644 --- a/src/Threejs/assets/src/controller.ts +++ b/src/Threejs/assets/src/controller.ts @@ -10,6 +10,7 @@ export type Material = { transparent: boolean; type: string; doubleSide: boolean; + skybox: boolean; } export type Mesh = { @@ -61,12 +62,18 @@ export default class extends Controller { let scene = new THREE.Scene(); const light = new THREE.AmbientLight(0x404040); // Lumière ambiante scene.add(light); + if (sceneValue.material.color) { scene.background = new THREE.Color(sceneValue.material.color); } if (sceneValue.material.map) { - scene.background = new THREE.TextureLoader().load(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[] = []; diff --git a/src/Threejs/composer.json b/src/Threejs/composer.json index 88978a87bc9..e1bd0701f62 100644 --- a/src/Threejs/composer.json +++ b/src/Threejs/composer.json @@ -28,20 +28,18 @@ } }, "require": { - "php": ">=8.1", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/stimulus-bundle": "^2.9.1" + "php": ">=8.3", + "symfony/stimulus-bundle": "^2.18.1" }, "require-dev": { - "symfony/framework-bundle": "^5.4|^6.0|^7.0", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "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/flex": "<1.13" + "symfony/ux-twig-component": "<2.21" }, "extra": { "thanks": { diff --git a/src/Threejs/src/Material/Material.php b/src/Threejs/src/Material/Material.php index 49596131cf3..3b8be71682a 100644 --- a/src/Threejs/src/Material/Material.php +++ b/src/Threejs/src/Material/Material.php @@ -25,6 +25,7 @@ public function __construct( public float $opacity = 1, public string $map = '', public bool $doubleSide = false, + public bool $skybox = false, ) { $this->transparent = $this->opacity < 1; From a8ca4330ae156a8f527d8d0acc19ad24bf97deb6 Mon Sep 17 00:00:00 2001 From: Sylvain Blondeau Date: Tue, 15 Apr 2025 23:35:06 +0200 Subject: [PATCH 3/4] add live support --- src/Threejs/assets/dist/controller.d.ts | 3 ++ src/Threejs/assets/dist/controller.js | 34 ++++++++---- src/Threejs/assets/src/controller.ts | 69 +++++++++++++++++++------ 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/Threejs/assets/dist/controller.d.ts b/src/Threejs/assets/dist/controller.d.ts index 391a9b710c4..fa9751515e8 100644 --- a/src/Threejs/assets/dist/controller.d.ts +++ b/src/Threejs/assets/dist/controller.d.ts @@ -38,7 +38,9 @@ export default class extends Controller { 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; @@ -48,4 +50,5 @@ export default class extends Controller { 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 index 4fce2701065..d7ad52df243 100644 --- a/src/Threejs/assets/dist/controller.js +++ b/src/Threejs/assets/dist/controller.js @@ -4,14 +4,25 @@ 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 renderer = new THREE.WebGLRenderer(); - const rendererValue = this.threeValue.renderer; - renderer.setSize(rendererValue.width ?? window.innerWidth, rendererValue.height ?? window.innerHeight); - this.element.appendChild(renderer.domElement); + 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); @@ -27,16 +38,15 @@ class default_1 extends Controller { } const cameras = []; for (let cameraData of rendererValue.cameras) { - cameras.push(this.createCamera(cameraData, renderer)); + cameras.push(this.createCamera(cameraData, this.renderer)); } for (let lightData of sceneValue.lights) { this.createLight(lightData, scene); } if (rendererValue.controls) { - this.setControls(cameras[0], renderer); + this.setControls(cameras[0], this.renderer); } for (let modelData of this.threeValue.renderer.scene.models) { - console.log(modelData); this.createModel(modelData, scene); } let animatedObjects = []; @@ -56,12 +66,12 @@ class default_1 extends Controller { mesh.position.y += animation.translation.y; mesh.position.z = animation.translation.z; } - renderer.render(scene, cameras[0]); + this.renderer?.render(scene, cameras[0]); requestAnimationFrame(animate); }; animate(); this.dispatchEvent('connect', { - renderer: renderer, + renderer: this.renderer, scene: scene, }); } @@ -156,6 +166,8 @@ class default_1 extends Controller { 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) { @@ -184,6 +196,10 @@ class default_1 extends Controller { dispatchEvent(name, payload) { this.dispatch(name, { detail: payload, prefix: 'ux:threejs' }); } + threeValueChanged() { + const threeValue = this.threeValue; + this.createScene(threeValue); + } } default_1.values = { three: Object, diff --git a/src/Threejs/assets/src/controller.ts b/src/Threejs/assets/src/controller.ts index 7abe29b89c2..6e01924d5b4 100644 --- a/src/Threejs/assets/src/controller.ts +++ b/src/Threejs/assets/src/controller.ts @@ -46,21 +46,32 @@ export default class extends Controller { 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 renderer = new THREE.WebGLRenderer(); - const rendererValue = this.threeValue.renderer; - renderer.setSize(rendererValue.width ?? window.innerWidth, rendererValue.height ?? window.innerHeight); - this.element.appendChild(renderer.domElement); + 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); // Lumière ambiante + const light = new THREE.AmbientLight(0x404040); scene.add(light); if (sceneValue.material.color) { @@ -68,17 +79,17 @@ export default class extends Controller { } if (sceneValue.material.map) { const texture = new THREE.TextureLoader().load(sceneValue.material.map); - if(sceneValue.material.skybox) - texture.mapping = THREE.EquirectangularReflectionMapping; + 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, renderer)); + cameras.push(this.createCamera(cameraData, this.renderer)); } /** lights */ @@ -88,12 +99,11 @@ export default class extends Controller { /** controls */ if (rendererValue.controls) { - this.setControls(cameras[0], renderer); + this.setControls(cameras[0], this.renderer); } /** load 3d models */ for (let modelData of this.threeValue.renderer.scene.models) { - console.log(modelData); this.createModel(modelData, scene); } @@ -117,7 +127,7 @@ export default class extends Controller { mesh.position.y += animation.translation.y; mesh.position.z = animation.translation.z; } - renderer.render(scene, cameras[0]); + this.renderer?.render(scene, cameras[0]); requestAnimationFrame(animate); }; @@ -125,7 +135,7 @@ export default class extends Controller { animate(); this.dispatchEvent('connect', { - renderer: renderer, + renderer: this.renderer, scene: scene, }); } @@ -242,6 +252,28 @@ export default class extends Controller { 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) { @@ -273,5 +305,12 @@ export default class extends Controller { private dispatchEvent(name: string, payload: any) { this.dispatch(name, { detail: payload, prefix: 'ux:threejs' }); } + + threeValueChanged(): void { + const threeValue = this.threeValue; + + this.createScene(threeValue); + + } } From b70c63552c00729853dfaa79e5cc5f98ca79c166 Mon Sep 17 00:00:00 2001 From: Sylvain Blondeau Date: Tue, 15 Apr 2025 23:36:00 +0200 Subject: [PATCH 4/4] add live support --- src/Threejs/src/Camera/Camera.php | 30 ++++++-- ...rthographicCamera.php => Orthographic.php} | 2 +- ...{PerspectiveCamera.php => Perspective.php} | 2 +- src/Threejs/src/Geometry/Box.php | 12 ++++ src/Threejs/src/Geometry/BufferGeometry.php | 11 +++ src/Threejs/src/Geometry/Cylinder.php | 14 ++++ src/Threejs/src/Geometry/Plane.php | 11 +++ src/Threejs/src/Geometry/Sphere.php | 10 +++ .../Light/{AmbientLight.php => Ambient.php} | 2 +- .../{DirectionalLight.php => Directional.php} | 25 ++++++- src/Threejs/src/Light/Light.php | 19 +++++ .../src/Live/ComponentWithThreeTrait.php | 72 +++++++++++++++++++ src/Threejs/src/Material/Material.php | 25 ++++++- src/Threejs/src/Material/MeshBasic.php | 1 - src/Threejs/src/Material/MeshPhong.php | 2 + src/Threejs/src/Mesh.php | 44 +++++++++--- .../src/Model/{GLTFModel.php => GLTF.php} | 2 +- src/Threejs/src/Model/Model.php | 25 +++++++ src/Threejs/src/Renderer.php | 24 ++++++- src/Threejs/src/Scene.php | 27 ++++++- src/Threejs/src/Three.php | 33 ++++++--- src/Threejs/src/Utils/Animation.php | 23 ++++++ src/Threejs/src/Utils/Vector3.php | 15 ++++ 23 files changed, 395 insertions(+), 36 deletions(-) rename src/Threejs/src/Camera/{OrthographicCamera.php => Orthographic.php} (94%) rename src/Threejs/src/Camera/{PerspectiveCamera.php => Perspective.php} (93%) rename src/Threejs/src/Light/{AmbientLight.php => Ambient.php} (91%) rename src/Threejs/src/Light/{DirectionalLight.php => Directional.php} (50%) create mode 100644 src/Threejs/src/Live/ComponentWithThreeTrait.php rename src/Threejs/src/Model/{GLTFModel.php => GLTF.php} (92%) diff --git a/src/Threejs/src/Camera/Camera.php b/src/Threejs/src/Camera/Camera.php index df253557d79..4a15ee697da 100644 --- a/src/Threejs/src/Camera/Camera.php +++ b/src/Threejs/src/Camera/Camera.php @@ -19,10 +19,30 @@ */ abstract class Camera { - public Vector3 $position; + public Vector3 $position; + public string $type; - public function __construct(?Vector3 $position = null) - { - $this->position = $position ?? new Vector3(0, 0, 5); - } + 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/OrthographicCamera.php b/src/Threejs/src/Camera/Orthographic.php similarity index 94% rename from src/Threejs/src/Camera/OrthographicCamera.php rename to src/Threejs/src/Camera/Orthographic.php index 37d333b426e..da65240ea67 100644 --- a/src/Threejs/src/Camera/OrthographicCamera.php +++ b/src/Threejs/src/Camera/Orthographic.php @@ -16,7 +16,7 @@ /** * @author Sylvain Blondeau */ -final class OrthographicCamera extends Camera +final class Orthographic extends Camera { public string $type = 'Orthographic'; diff --git a/src/Threejs/src/Camera/PerspectiveCamera.php b/src/Threejs/src/Camera/Perspective.php similarity index 93% rename from src/Threejs/src/Camera/PerspectiveCamera.php rename to src/Threejs/src/Camera/Perspective.php index 5b89571d898..1886493f7b6 100644 --- a/src/Threejs/src/Camera/PerspectiveCamera.php +++ b/src/Threejs/src/Camera/Perspective.php @@ -16,7 +16,7 @@ /** * @author Sylvain Blondeau */ -final class PerspectiveCamera extends Camera +final class Perspective extends Camera { public string $type = 'Perspective'; diff --git a/src/Threejs/src/Geometry/Box.php b/src/Threejs/src/Geometry/Box.php index 5104e195933..5eb1d55361f 100644 --- a/src/Threejs/src/Geometry/Box.php +++ b/src/Threejs/src/Geometry/Box.php @@ -28,4 +28,16 @@ public function __construct( 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 index 92f56c36349..d912be5bf8e 100644 --- a/src/Threejs/src/Geometry/BufferGeometry.php +++ b/src/Threejs/src/Geometry/BufferGeometry.php @@ -23,5 +23,16 @@ 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 index dccb40a5ba9..0143919a0e2 100644 --- a/src/Threejs/src/Geometry/Cylinder.php +++ b/src/Threejs/src/Geometry/Cylinder.php @@ -33,6 +33,20 @@ public function __construct( 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 index cad54ce2d85..e616d6d6c37 100644 --- a/src/Threejs/src/Geometry/Plane.php +++ b/src/Threejs/src/Geometry/Plane.php @@ -28,4 +28,15 @@ public function __construct( 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 index e9b5b468aa4..7cabb3d6b35 100644 --- a/src/Threejs/src/Geometry/Sphere.php +++ b/src/Threejs/src/Geometry/Sphere.php @@ -27,4 +27,14 @@ public function __construct( 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/AmbientLight.php b/src/Threejs/src/Light/Ambient.php similarity index 91% rename from src/Threejs/src/Light/AmbientLight.php rename to src/Threejs/src/Light/Ambient.php index f6584cfe824..af8b026e51c 100644 --- a/src/Threejs/src/Light/AmbientLight.php +++ b/src/Threejs/src/Light/Ambient.php @@ -14,7 +14,7 @@ /** * @author Sylvain Blondeau */ -final class AmbientLight extends Light +final class Ambient extends Light { public const string TYPE = 'Ambient'; diff --git a/src/Threejs/src/Light/DirectionalLight.php b/src/Threejs/src/Light/Directional.php similarity index 50% rename from src/Threejs/src/Light/DirectionalLight.php rename to src/Threejs/src/Light/Directional.php index a2a688c1b33..cb0d9788d72 100644 --- a/src/Threejs/src/Light/DirectionalLight.php +++ b/src/Threejs/src/Light/Directional.php @@ -16,7 +16,7 @@ /** * @author Sylvain Blondeau */ -final class DirectionalLight extends Light +final class Directional extends Light { public const string TYPE = 'Directional'; @@ -30,5 +30,28 @@ public function __construct( ) { 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 index ff816e61140..f725c5f3f4d 100644 --- a/src/Threejs/src/Light/Light.php +++ b/src/Threejs/src/Light/Light.php @@ -22,4 +22,23 @@ 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 index 3b8be71682a..67d1a1597f9 100644 --- a/src/Threejs/src/Material/Material.php +++ b/src/Threejs/src/Material/Material.php @@ -17,7 +17,6 @@ */ abstract class Material { - public bool $transparent; public string $type; public function __construct( @@ -26,10 +25,32 @@ public function __construct( 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 index ff7217d77b0..d103f52dc34 100644 --- a/src/Threejs/src/Material/MeshBasic.php +++ b/src/Threejs/src/Material/MeshBasic.php @@ -20,5 +20,4 @@ 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 index ab345432e46..91b0d44f2f6 100644 --- a/src/Threejs/src/Material/MeshPhong.php +++ b/src/Threejs/src/Material/MeshPhong.php @@ -18,5 +18,7 @@ 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 index 2abe84b8745..f57c047f0a7 100644 --- a/src/Threejs/src/Mesh.php +++ b/src/Threejs/src/Mesh.php @@ -32,25 +32,26 @@ public function __construct( public ?Animation $animation = new Animation(), public Vector3 $position = new Vector3(), public Vector3 $angle = new Vector3(), - ) - { - } + ) {} - public function setGeometry(BufferGeometry $geometry): self { + public function setGeometry(BufferGeometry $geometry): self + { $this->geometry = $geometry; - + return $this; } - public function setAnimation(Animation $animation): self { + public function setAnimation(Animation $animation): self + { $this->animation = $animation; - + return $this; } - public function setMaterial(Material $material): self { + public function setMaterial(Material $material): self + { $this->material = $material; - + return $this; } @@ -63,11 +64,34 @@ public function setAngle(float $aX = 0, float $aY = 0, float $aZ = 0): self return $this; } - public function setPosition(float $x = 0, float $y = 0, float $z = 0): self { + 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/GLTFModel.php b/src/Threejs/src/Model/GLTF.php similarity index 92% rename from src/Threejs/src/Model/GLTFModel.php rename to src/Threejs/src/Model/GLTF.php index 0e70969f56c..92a6ffcdbd9 100644 --- a/src/Threejs/src/Model/GLTFModel.php +++ b/src/Threejs/src/Model/GLTF.php @@ -16,7 +16,7 @@ * @author Sylvain Blondeau * */ -final class GLTFModel extends Model +final class GLTF extends Model { public const string TYPE = 'GLTF'; diff --git a/src/Threejs/src/Model/Model.php b/src/Threejs/src/Model/Model.php index b38d012d530..37983b87ca3 100644 --- a/src/Threejs/src/Model/Model.php +++ b/src/Threejs/src/Model/Model.php @@ -29,4 +29,29 @@ public function __construct( 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 index 32c551e5783..6ae1dac8e94 100644 --- a/src/Threejs/src/Renderer.php +++ b/src/Threejs/src/Renderer.php @@ -12,7 +12,7 @@ namespace Symfony\UX\Threejs; use Symfony\UX\Threejs\Camera\Camera; -use Symfony\UX\Threejs\Camera\PerspectiveCamera; +use Symfony\UX\Threejs\Camera\Perspective; /** * @author Sylvain Blondeau @@ -24,7 +24,7 @@ final class Renderer public function __construct( public Scene $scene = new Scene(), public bool $controls = true, - public array $cameras = [new PerspectiveCamera()], + public array $cameras = [new Perspective()], public ?int $width = 300, public ?int $height = 300, ) {} @@ -42,4 +42,24 @@ public function addCamera(Camera $camera): self 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 index 4b8f850d6f5..79b3b19c340 100644 --- a/src/Threejs/src/Scene.php +++ b/src/Threejs/src/Scene.php @@ -14,7 +14,7 @@ use Symfony\UX\Threejs\Light\Light; use Symfony\UX\Threejs\Model\Model; use Symfony\UX\Threejs\Material\Material; -use Symfony\UX\Threejs\Light\AmbientLight; +use Symfony\UX\Threejs\Light\Ambient; use Symfony\UX\Threejs\Material\MeshBasic; /** @@ -26,7 +26,7 @@ final class Scene { public function __construct( public ?Material $material = new MeshBasic(), - public array $lights = [new AmbientLight()], + public array $lights = [new Ambient()], public array $meshes = [], public array $models = [], ) @@ -40,7 +40,7 @@ public function setMaterial(Material $material): self return $this; } - public function addLight(Light $light = new AmbientLight()): self + public function addLight(Light $light = new Ambient()): self { $this->lights[] = $light; @@ -58,4 +58,25 @@ public function addModel(Model $model): self { 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 index 54a7b6fcea4..44bc6b6acff 100644 --- a/src/Threejs/src/Three.php +++ b/src/Threejs/src/Three.php @@ -23,17 +23,16 @@ */ class Three { - public Renderer $renderer; public function __construct( - private int $width = 300, - private int $height = 300, + public int $width = 300, + public int $height = 300, + public Renderer $renderer = new Renderer(), ) { - $this->renderer = new Renderer( - scene: new Scene(), - width: $this->width, - height: $this->height - ); + $this->renderer->scene ??= new Scene(); + $this->renderer->width ??= $this->width; + $this->renderer->height ??= $this->height; + } public function addCamera(Camera $camera): self { @@ -83,4 +82,22 @@ 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/Utils/Animation.php b/src/Threejs/src/Utils/Animation.php index 549627cd669..36fdd8873e5 100644 --- a/src/Threejs/src/Utils/Animation.php +++ b/src/Threejs/src/Utils/Animation.php @@ -53,4 +53,27 @@ public function scale(float $sX = 0, float $sY = 0, float $sZ = 0): self { 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 index d6fc9d3aa85..0e66cb4c66e 100644 --- a/src/Threejs/src/Utils/Vector3.php +++ b/src/Threejs/src/Utils/Vector3.php @@ -22,4 +22,19 @@ public function __construct( 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, + ]; + } }