├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── samples ├── helloWorld │ ├── main.py │ ├── models │ │ ├── environment.bam │ │ ├── panda-model.bam │ │ └── panda-walk4.bam │ ├── project.xml │ └── scenes │ │ └── hello_world.xml └── pandaObjects │ ├── main.py │ ├── models │ ├── box.bam │ ├── environment.bam │ ├── panda-model.bam │ └── panda-walk4.bam │ ├── project.xml │ ├── scenes │ ├── helloWorld.xml │ └── test.xml │ └── scripts │ ├── cameraSpin.py │ ├── pandaWalk.py │ └── sine.py └── src ├── data └── images │ ├── application-sidebar-list.png │ ├── arrow-curve-flip.png │ ├── arrow-curve.png │ ├── disk-black-pencil.png │ ├── disk-black.png │ ├── document.png │ ├── folder-horizontal-open.png │ ├── globe.png │ ├── layout-both.png │ ├── layout-game.png │ ├── move.png │ ├── point.png │ ├── rotate.png │ ├── scale.png │ └── select.png ├── main.py ├── p3d ├── __init__.py ├── camera.py ├── commonUtils.py ├── constants.py ├── displayShading.py ├── editorCamera.py ├── frameRate.py ├── functions.py ├── geometry.py ├── marquee.py ├── mouse.py ├── mousePicker.py ├── nodePathObject.py ├── object.py ├── pandaBehaviour.py ├── pandaManager.py ├── pandaObject.py ├── singleTask.py └── wxPanda.py ├── pandaEditor ├── __init__.py ├── actions.py ├── assetManager.py ├── commands.py ├── constants.py ├── directorywatcher.py ├── dragdropmanager.py ├── dragdroptarget.py ├── game │ ├── __init__.py │ ├── nodes │ │ ├── __init__.py │ │ ├── actor.py │ │ ├── attributes.py │ │ ├── base.py │ │ ├── basemetaclass.py │ │ ├── bullet.py │ │ ├── camera.py │ │ ├── collision.py │ │ ├── componentmetaclass.py │ │ ├── constants.py │ │ ├── fog.py │ │ ├── lensnode.py │ │ ├── lights.py │ │ ├── manager.py │ │ ├── modelnode.py │ │ ├── modelroot.py │ │ ├── nodepath.py │ │ ├── nongraphobject.py │ │ ├── pandanode.py │ │ ├── particleeffect.py │ │ ├── sceneroot.py │ │ ├── showbasedefaults.py │ │ └── texture.py │ ├── plugins │ │ ├── __init__.py │ │ ├── base.py │ │ ├── ibase.py │ │ └── manager.py │ ├── scene.py │ ├── sceneparser.py │ ├── showbase.py │ └── utils.py ├── gizmos │ ├── __init__.py │ ├── axis.py │ ├── base.py │ ├── constants.py │ ├── manager.py │ ├── rotation.py │ ├── scale.py │ └── translation.py ├── nodes │ ├── __init__.py │ ├── actor.py │ ├── attributes.py │ ├── base.py │ ├── bullet.py │ ├── collision.py │ ├── constants.py │ ├── lensnode.py │ ├── lights.py │ ├── manager.py │ ├── modelroot.py │ ├── nodepath.py │ ├── nongraphobject.py │ ├── particleeffect.py │ ├── sceneroot.py │ ├── showbasedefaults.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_bullet.py │ │ ├── test_collision.py │ │ ├── test_lights.py │ │ ├── test_nodepath.py │ │ ├── test_showbasedefaults.py │ │ ├── testbasemixin.py │ │ └── testmixin.py │ └── texture.py ├── plugins │ ├── __init__.py │ ├── base.py │ └── manager.py ├── project.py ├── scene.py ├── sceneparser.py ├── selection.py ├── showbase.py ├── tests │ ├── __init__.py │ ├── test_actions.py │ └── test_project.py ├── ui │ ├── __init__.py │ ├── createdialog.py │ ├── document.py │ ├── lightLinkerPanel.py │ ├── mainFrame.py │ ├── preferenceseditor.py │ ├── projectSettingsPanel.py │ ├── properties.py │ ├── propertiesPanel.py │ ├── resourcesPanel.py │ ├── sceneGraphBasePanel.py │ ├── sceneGraphPanel.py │ └── viewport.py └── utils.py ├── plugins ├── example_editor │ ├── plugin.py │ └── plugin.yapsy-plugin ├── example_game │ ├── plugin.py │ └── plugin.yapsy-plugin ├── helpers │ ├── data │ │ ├── ambient_light.egg │ │ ├── camera.egg │ │ ├── directional_light.egg │ │ ├── point_light.egg │ │ ├── spotlight.egg │ │ └── vertex_colours.sha │ ├── plugin.py │ └── plugin.yapsy-plugin └── primitives │ ├── plugin.py │ └── plugin.yapsy-plugin ├── run_tests.py └── wxExtra ├── __init__.py ├── actionItem.py ├── auiManagerConfig.py ├── customAuiToolBar.py ├── customListCtrl.py ├── customMenu.py ├── customTreeCtrl.py ├── dirTreeCtrl.py ├── logpanel.py ├── propertyGrid.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .idea/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyight (C) 2016-2022 James Davies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | panda3d-editor 2 | ============== 3 | 4 | A simple, lightweight editor for the Panda3D engine. 5 | 6 | Features include: 7 | 8 | * Ability to add lights and models to a scene 9 | * Gizmos to allow easy transformation of nodes 10 | * Ability to edit node properties 11 | * Node duplication 12 | * Undo / redo 13 | * Project management, including ablity to build a project to a p3d multifile 14 | * Save / restore scene in xml format 15 | * Support for user created plugins 16 | 17 | ## Installation 18 | 19 | Once you have wxPython installed you should be able to start the editor from panda3d-editor\src\main.py. No additional python path modification is necessary. If you are trying to run your project main.py without building to p3d, you will need the following directories to be found on PYTHONPATH: 20 | * src\p3d 21 | * src\pandaEditor\game 22 | 23 | ## Requires 24 | 25 | * Panda3D 26 | * wxPython 27 | 28 | ## Usage 29 | 30 | To give the editor a spin, try the following: 31 | 32 | * Create a new project (File -> Project -> New) 33 | * Import .egg or .bam files to your project (File -> Import...) 34 | * Middle mouse drag them into the scene from the resource panel under models/ 35 | * Translate, rotate and scale them as your desire 36 | * Set up some lights (Create -> Lights) 37 | * Save the scene as test.xml (File -> Save) 38 | * Build your project (File -> Build) 39 | You should now have a p3d file which runs your scene. 40 | 41 | ## Keys 42 | 43 | * 4 - Wireframe view 44 | * 5 - Shaded view 45 | * 6 - Textured view 46 | * Q - Select 47 | * W - Translate 48 | * E - Rotate 49 | * R - Scale 50 | * Z - Undo 51 | * Shift-Z - Redo 52 | * F - Frame selection 53 | * Backspace - Delete 54 | * Ctrl-D - Duplicate 55 | * Arrow up - Select parent 56 | * Arrow down - Select child 57 | * Arrow left - Select previous child 58 | * Arrow right - Select next child 59 | * Mouse left - Pointing and selecting 60 | * Middle mouse - Your "doing" button. Use then to reparent nodes in the scene graph, or drag-drop models into the scene. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "panda3d-editor" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Derfies "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | wxPython = "^4.1.1" 10 | PyPubSub = "^4.0.3" 11 | Yapsy = "^1.12.2" 12 | intervaltree = "^3.1.0" 13 | tabulate = "^0.8.9" 14 | pyquaternion = "^0.9.9" 15 | transformations = "^2021.6.6" 16 | matplotlib = "^3.5.1" 17 | networkx = "^2.6.3" 18 | simple-settings = "^1.2.0" 19 | pycsg = "^0.3.3" 20 | pyperclip = "^1.8.2" 21 | ordered-set = "^4.1.0" 22 | pyclipper = "^1.3.0" 23 | 24 | [tool.poetry.dev-dependencies] 25 | 26 | [build-system] 27 | requires = ["poetry-core>=1.0.0"] 28 | build-backend = "poetry.core.masonry.api" 29 | -------------------------------------------------------------------------------- /samples/helloWorld/main.py: -------------------------------------------------------------------------------- 1 | from math import pi, sin, cos 2 | 3 | import panda3d.core as pc 4 | from direct.task import Task 5 | from direct.interval.IntervalGlobal import Sequence 6 | 7 | from game.showbase import ShowBase 8 | 9 | 10 | class MyApp(ShowBase): 11 | 12 | def __init__(self): 13 | super().__init__() 14 | 15 | # Load the level. 16 | self.load_scene('scenes/hello_world.xml') 17 | 18 | # Disable the camera trackball controls. 19 | self.disable_mouse() 20 | 21 | # Add the spinCameraTask procedure to the task manager. 22 | self.task_mgr.add(self.spin_camera_task, 'spin_camera_task') 23 | 24 | # Find the panda actor placed in the scene. 25 | panda_component = self.node_manager.wrap(self.render.find('panda_walk_character')) 26 | self.panda_actor = panda_component.GetActor() 27 | self.panda_actor.loop('walk') 28 | 29 | # Create the four lerp intervals needed for the panda to 30 | # walk back and forth. 31 | pandaPosInterval1 = self.panda_actor.posInterval( 32 | 13, 33 | pc.Point3(0, -10, 0), 34 | startPos=pc.Point3(0, 10, 0) 35 | ) 36 | pandaPosInterval2 = self.panda_actor.posInterval( 37 | 13, 38 | pc.Point3(0, 10, 0), 39 | startPos=pc.Point3(0, -10, 0) 40 | ) 41 | pandaHprInterval1 = self.panda_actor.hprInterval( 42 | 3, 43 | pc.Point3(180, 0, 0), 44 | startHpr=pc.Point3(0, 0, 0) 45 | ) 46 | pandaHprInterval2 = self.panda_actor.hprInterval( 47 | 3, 48 | pc.Point3(0, 0, 0), 49 | startHpr=pc.Point3(180, 0, 0) 50 | ) 51 | 52 | # Create and play the sequence that coordinates the intervals. 53 | self.panda_pace = Sequence( 54 | pandaPosInterval1, 55 | pandaHprInterval1, 56 | pandaPosInterval2, 57 | pandaHprInterval2, 58 | name='panda_pace' 59 | ) 60 | self.panda_pace.loop() 61 | 62 | # Define a procedure to move the camera. 63 | def spin_camera_task(self, task): 64 | degrees = task.time * 6.0 65 | radians = degrees * (pi / 180.0) 66 | self.camera.setPos(20 * sin(radians), -20.0 * cos(radians), 3) 67 | self.camera.setHpr(degrees, 0, 0) 68 | return Task.cont 69 | 70 | 71 | app = MyApp() 72 | app.run() -------------------------------------------------------------------------------- /samples/helloWorld/models/environment.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/helloWorld/models/environment.bam -------------------------------------------------------------------------------- /samples/helloWorld/models/panda-model.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/helloWorld/models/panda-model.bam -------------------------------------------------------------------------------- /samples/helloWorld/models/panda-walk4.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/helloWorld/models/panda-walk4.bam -------------------------------------------------------------------------------- /samples/helloWorld/project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /samples/helloWorld/scenes/hello_world.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /samples/pandaObjects/main.py: -------------------------------------------------------------------------------- 1 | from direct.directbase import DirectStart 2 | 3 | import game 4 | 5 | 6 | # Create game base and load level 7 | game = game.Base() 8 | game.load_plugins() 9 | game.load('scenes/helloWorld.xml') 10 | 11 | # Initialise the PandaObject manager and start all scripts. 12 | base.pandaMgr.Init() 13 | base.pandaMgr.start() 14 | 15 | run() -------------------------------------------------------------------------------- /samples/pandaObjects/models/box.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/pandaObjects/models/box.bam -------------------------------------------------------------------------------- /samples/pandaObjects/models/environment.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/pandaObjects/models/environment.bam -------------------------------------------------------------------------------- /samples/pandaObjects/models/panda-model.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/pandaObjects/models/panda-model.bam -------------------------------------------------------------------------------- /samples/pandaObjects/models/panda-walk4.bam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/samples/pandaObjects/models/panda-walk4.bam -------------------------------------------------------------------------------- /samples/pandaObjects/project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /samples/pandaObjects/scenes/helloWorld.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /samples/pandaObjects/scenes/test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /samples/pandaObjects/scripts/cameraSpin.py: -------------------------------------------------------------------------------- 1 | from math import pi, sin, cos 2 | 3 | import p3d 4 | 5 | 6 | class CameraSpin(p3d.PandaBehaviour): 7 | 8 | def OnUpdate(self, task): 9 | angleDegrees = task.time * 6.0 10 | angleRadians = angleDegrees * (pi / 180.0) 11 | self.np.setPos(20 * sin(angleRadians), -20.0 * cos(angleRadians), 3) 12 | self.np.setHpr(angleDegrees, 0, 0) -------------------------------------------------------------------------------- /samples/pandaObjects/scripts/pandaWalk.py: -------------------------------------------------------------------------------- 1 | from panda3d.core import Point3 2 | from direct.interval.IntervalGlobal import Sequence 3 | 4 | import p3d 5 | 6 | 7 | class PandaWalk(p3d.PandaBehaviour): 8 | 9 | animName = str 10 | 11 | def __init__(self, *args, **kwargs): 12 | p3d.PandaBehaviour.__init__(self, *args, **kwargs) 13 | 14 | self.animName = '' 15 | 16 | def OnStart(self): 17 | 18 | # Find the panda actor placed in the scene. 19 | pandaWrpr = base.node_manager.wrap(render.find('panda_walk_character')) 20 | self.pandaActor = pandaWrpr.GetActor() 21 | self.pandaActor.loop(self.animName) 22 | 23 | # Create the four lerp intervals needed for the panda to 24 | # walk back and forth. 25 | pandaPosInterval1 = self.pandaActor.posInterval(13, 26 | Point3(0, -10, 0), 27 | startPos=Point3(0, 10, 0)) 28 | pandaPosInterval2 = self.pandaActor.posInterval(13, 29 | Point3(0, 10, 0), 30 | startPos=Point3(0, -10, 0)) 31 | pandaHprInterval1 = self.pandaActor.hprInterval(3, 32 | Point3(180, 0, 0), 33 | startHpr=Point3(0, 0, 0)) 34 | pandaHprInterval2 = self.pandaActor.hprInterval(3, 35 | Point3(0, 0, 0), 36 | startHpr=Point3(180, 0, 0)) 37 | 38 | # Create and play the sequence that coordinates the intervals. 39 | self.pandaPace = Sequence(pandaPosInterval1, 40 | pandaHprInterval1, 41 | pandaPosInterval2, 42 | pandaHprInterval2, 43 | name='pandaPace') 44 | self.pandaPace.loop() 45 | -------------------------------------------------------------------------------- /samples/pandaObjects/scripts/sine.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import p3d 4 | 5 | 6 | class Sine(p3d.PandaBehaviour): 7 | 8 | amplitude = float 9 | 10 | def __init__(self, *args, **kwargs): 11 | p3d.PandaBehaviour.__init__(self, *args, **kwargs) 12 | 13 | self.amplitude = 10 14 | 15 | def OnUpdate(self, task): 16 | self.np.setZ(math.sin(task.time) * self.amplitude) 17 | -------------------------------------------------------------------------------- /src/data/images/application-sidebar-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/application-sidebar-list.png -------------------------------------------------------------------------------- /src/data/images/arrow-curve-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/arrow-curve-flip.png -------------------------------------------------------------------------------- /src/data/images/arrow-curve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/arrow-curve.png -------------------------------------------------------------------------------- /src/data/images/disk-black-pencil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/disk-black-pencil.png -------------------------------------------------------------------------------- /src/data/images/disk-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/disk-black.png -------------------------------------------------------------------------------- /src/data/images/document.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/document.png -------------------------------------------------------------------------------- /src/data/images/folder-horizontal-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/folder-horizontal-open.png -------------------------------------------------------------------------------- /src/data/images/globe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/globe.png -------------------------------------------------------------------------------- /src/data/images/layout-both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/layout-both.png -------------------------------------------------------------------------------- /src/data/images/layout-game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/layout-game.png -------------------------------------------------------------------------------- /src/data/images/move.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/move.png -------------------------------------------------------------------------------- /src/data/images/point.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/point.png -------------------------------------------------------------------------------- /src/data/images/rotate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/rotate.png -------------------------------------------------------------------------------- /src/data/images/scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/scale.png -------------------------------------------------------------------------------- /src/data/images/select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/data/images/select.png -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from panda3d.core import ConfigVariableBool, loadPrcFileData 4 | 5 | 6 | logging.basicConfig( 7 | level=logging.INFO, 8 | format='%(asctime)s [%(levelname)s] %(message)s', 9 | ) 10 | 11 | 12 | # Stops the default panda window showing. 13 | loadPrcFileData('startup', 'window-type none') 14 | 15 | editor_mode = ConfigVariableBool('editor_mode', False) 16 | editor_mode.set_value(True) 17 | from pandaEditor.showbase import ShowBase 18 | ShowBase().run() 19 | -------------------------------------------------------------------------------- /src/p3d/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/p3d/__init__.py -------------------------------------------------------------------------------- /src/p3d/constants.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | 3 | 4 | X_AXIS = pm.Vec3(1, 0, 0) 5 | Y_AXIS = pm.Vec3(0, 1, 0) 6 | Z_AXIS = pm.Vec3(0, 0, 1) -------------------------------------------------------------------------------- /src/p3d/displayShading.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.DirectObject import DirectObject 2 | 3 | 4 | class DisplayShading(DirectObject): 5 | 6 | """Toggles display shading.""" 7 | 8 | def SetWireframe(self, value): 9 | if value: 10 | base.wireframeOn() 11 | else: 12 | base.wireframeOff() 13 | 14 | def SetTexture(self, value): 15 | if value: 16 | base.textureOn() 17 | else: 18 | base.textureOff() 19 | 20 | def Wireframe(self): 21 | self.SetWireframe(True) 22 | self.SetTexture(False) 23 | 24 | def Shade(self): 25 | self.SetWireframe(False) 26 | self.SetTexture(False) 27 | 28 | def Texture(self): 29 | self.SetWireframe(False) 30 | self.SetTexture(True) -------------------------------------------------------------------------------- /src/p3d/editorCamera.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | from direct.showbase.PythonUtil import getBase as get_base 3 | from panda3d.core import Vec2, Vec3 4 | 5 | from p3d.camera import Camera, CAM_USE_DEFAULT, CAM_VIEWPORT_AXES 6 | from p3d.mouse import Mouse, MOUSE_ALT 7 | 8 | 9 | class EditorCamera(Mouse, Camera): 10 | 11 | """Base editor camera class.""" 12 | 13 | def __init__(self, *args, **kwargs): 14 | self.orbit_sensitivity = kwargs.pop('orbit_sensitivity', 1) 15 | self.dolly_sensitivity = kwargs.pop('dolly_sensitivity', 1) 16 | self.zoom_sensitivity = kwargs.pop('zoom_sensitivity', 1) 17 | kwargs['pos'] = kwargs.pop('pos', (-250, -250, 200)) 18 | kwargs['style'] = kwargs.pop( 19 | 'style', 20 | CAM_USE_DEFAULT | CAM_VIEWPORT_AXES 21 | ) 22 | super().__init__(*args, **kwargs) 23 | 24 | # Create mouse 25 | base.disableMouse() 26 | # self.mouse = Mouse('mouse', *args, **kwargs) 27 | # self.mouse.Start() 28 | 29 | def OnUpdate(self, task): 30 | """ 31 | Task to control mouse events. Gets called every frame and will update 32 | the scene accordingly. 33 | 34 | """ 35 | super().OnUpdate(task) 36 | 37 | # Return if no mouse is found or alt not down 38 | if not self.mouseWatcherNode.hasMouse() or not MOUSE_ALT in self.modifiers: 39 | return 40 | 41 | dx = self.dx 42 | dy = self.dy 43 | 44 | #print(globalClock.getDt()) 45 | 46 | win_size = get_base().win.get_size() 47 | #rx, ry = 1600.0 / win_size.x, 900.0 / win_size.y 48 | # #print(rx, ry) 49 | 50 | # dy *= ry 51 | 52 | #print(task.dt) 53 | 54 | #dx *= task.dt * 1000.0 55 | #dy *= task.dt * 1000.0 56 | dx *= 0.1 57 | dy *= 0.1 58 | 59 | 60 | corrected_dx = dx * 1.0 / win_size.x * win_size.x 61 | corrected_dy = dy * 1.0 / win_size.y * win_size.y 62 | 63 | ''' 64 | #get the real size of the viewport 65 | var root_size = $"/root".size 66 | 67 | #calculate black strip 68 | var correction = (OS.get_window_size() - root_size)/2.0 69 | 70 | #we use fixed resolution of 1600 * 900 71 | var ratio = Vector2(1600.0, 900.0)/root_size 72 | 73 | var real_mouse_position = (get_viewport().get_mouse_position()-correction)*ratio 74 | ''' 75 | 76 | # Attempt to compensate for window size. 77 | # dx *= 1.0 / base.win.getXSize() * 500 78 | # dy *= 1.0 / base.win.getYSize() * 500 79 | 80 | # print('corrected_dx:', corrected_dx) 81 | 82 | # ORBIT - If left mouse down 83 | if self.buttons[0]: 84 | self.Orbit(Vec2( 85 | corrected_dx * self.orbit_sensitivity, 86 | corrected_dy * self.orbit_sensitivity, 87 | )) 88 | 89 | # DOLLY - If middle mouse down 90 | elif self.buttons[1]: 91 | self.Move(Vec3( 92 | corrected_dx * self.dolly_sensitivity, 93 | 0, 94 | -corrected_dy * self.dolly_sensitivity, 95 | )) 96 | 97 | # ZOOM - If right mouse down 98 | elif self.buttons[2]: 99 | self.Move(Vec3( 100 | 0, 101 | -corrected_dx * self.zoom_sensitivity, 102 | 0, 103 | )) 104 | -------------------------------------------------------------------------------- /src/p3d/frameRate.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.DirectObject import DirectObject 2 | 3 | 4 | class FrameRate(DirectObject): 5 | 6 | """Toggles displaying the framerate with F12.""" 7 | 8 | def __init__(self): 9 | self.state = False 10 | self.accept('f12', self.Toggle) 11 | 12 | def Toggle(self): 13 | self.state = not self.state 14 | getBase().setFrameRateMeter(self.state) -------------------------------------------------------------------------------- /src/p3d/functions.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | 3 | 4 | def Str2Bool(string): 5 | if string.lower() == 'true': 6 | return True 7 | return False 8 | 9 | 10 | def FloatTuple2Str(flts): 11 | return ' '.join([str(flt) for flt in flts]) 12 | 13 | 14 | def Str2FloatTuple(string): 15 | buffer = string.split(' ') 16 | return tuple([float(elem) for elem in buffer]) 17 | 18 | 19 | def Str2Vec2(string): 20 | buffer = string.split(' ') 21 | return pm.Vec2(*[float(buffer[i]) for i in range(2)]) 22 | 23 | 24 | def Str2Vec3(string): 25 | buffer = string.split(' ') 26 | return pm.Vec3(*[float(buffer[i]) for i in range(3)]) 27 | 28 | 29 | def Str2Vec4(string): 30 | buffer = string.split(' ') 31 | return pm.Vec4(*[float(buffer[i]) for i in range(4)]) 32 | 33 | 34 | def Str2Point2(string): 35 | buffer = string.split(' ') 36 | return pm.Point2(*[float(buffer[i]) for i in range(2)]) 37 | 38 | 39 | def Str2Point3(string): 40 | buffer = string.split(' ') 41 | return pm.Point3(*[float(buffer[i]) for i in range(3)]) 42 | 43 | 44 | def Str2Point4(string): 45 | buffer = string.split(' ') 46 | return pm.Point4(*[float(buffer[i]) for i in range(4)]) 47 | 48 | 49 | def Mat42Str(mat): 50 | buffer = [FloatTuple2Str(mat.getRow(i)) for i in range(4)] 51 | return ' '.join(buffer) 52 | 53 | 54 | def Str2Mat4(string): 55 | mat = pm.Mat4() 56 | mat.set(*Str2FloatTuple(string)) 57 | return mat -------------------------------------------------------------------------------- /src/p3d/marquee.py: -------------------------------------------------------------------------------- 1 | from panda3d.core import NodePath, CardMaker, LineSegs, Point2 2 | 3 | from p3d.singleTask import SingleTask 4 | 5 | 6 | TOLERANCE = 1e-3 7 | 8 | 9 | class Marquee(NodePath, SingleTask): 10 | 11 | """Class representing a 2D marquee drawn by the mouse.""" 12 | 13 | def __init__(self, *args, **kwargs): 14 | colour = kwargs.pop('colour', (1, 1, 1, .2)) 15 | 16 | # Create a card maker 17 | cm = CardMaker('foo') 18 | cm.setFrame(0, 1, 0, 1) 19 | 20 | # Init the node path, wrapping the card maker to make a rectangle 21 | NodePath.__init__(self, cm.generate()) 22 | SingleTask.__init__(self, *args, **kwargs) 23 | self.setColor(colour) 24 | self.setTransparency(1) 25 | self.reparentTo(self.root2d) 26 | self.hide() 27 | 28 | # Create the rectangle border 29 | ls = LineSegs() 30 | ls.moveTo(0, 0, 0) 31 | ls.drawTo(1, 0, 0) 32 | ls.drawTo(1, 0, 1) 33 | ls.drawTo(0, 0, 1) 34 | ls.drawTo(0, 0, 0) 35 | 36 | # Attach border to rectangle 37 | self.attachNewNode(ls.create()) 38 | 39 | def OnUpdate(self, task): 40 | """ 41 | Called every frame to keep the marquee scaled to fit the region marked 42 | by the mouse's initial position and the current mouse position. 43 | """ 44 | # Check for mouse first, in case the mouse is outside the Panda window 45 | if self.mouseWatcherNode.hasMouse(): 46 | 47 | # Get the other marquee point and scale to fit 48 | pos = self.mouseWatcherNode.getMouse() - self.initMousePos 49 | self.setScale(pos[0] if pos[0] else TOLERANCE, 1, pos[1] if pos[1] else TOLERANCE) 50 | 51 | def OnStart(self): 52 | 53 | # Move the marquee to the mouse position and show it 54 | self.initMousePos = Point2(self.mouseWatcherNode.getMouse()) 55 | self.setPos(self.initMousePos[0], 1, self.initMousePos[1]) 56 | self.show() 57 | 58 | def OnStop(self): 59 | 60 | # Hide the marquee 61 | self.hide() 62 | 63 | def IsNodePathInside(self, np): 64 | """Test if the specified node path lies within the marquee area.""" 65 | npWorldPos = np.getPos(self.rootNp) 66 | p3 = self.camera.getRelativePoint(self.rootNp, npWorldPos) 67 | 68 | # Convert it through the lens to render2d coordinates 69 | p2 = Point2() 70 | if not self.camera.GetLens().project(p3, p2): 71 | return False 72 | 73 | # Test point is within bounds of the marquee 74 | min, max = self.getTightBounds() 75 | if (p2.getX() > min.getX() and p2.getX() < max.getX() and 76 | p2.getY() > min.getZ() and p2.getY() < max.getZ()): 77 | return True 78 | 79 | return False -------------------------------------------------------------------------------- /src/p3d/mouse.py: -------------------------------------------------------------------------------- 1 | from p3d.singleTask import SingleTask 2 | 3 | 4 | MOUSE_ALT = 0 5 | MOUSE_CTRL = 1 6 | 7 | 8 | class Mouse(SingleTask): 9 | 10 | """Class representing the mouse.""" 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | 15 | self.x = 0 16 | self.y = 0 17 | self.dx = 0 18 | self.dy = 0 19 | self.buttons = [False, False, False] 20 | self.modifiers = [] 21 | 22 | # Bind button events 23 | self.accept('alt', self.SetModifier, [MOUSE_ALT]) 24 | self.accept('alt-up', self.ClearModifier, [MOUSE_ALT]) 25 | self.accept('control', self.SetModifier, [MOUSE_CTRL]) 26 | self.accept('control-up', self.ClearModifier, [MOUSE_CTRL]) 27 | 28 | self.accept('alt-mouse1', self.SetButton, [0, True]) 29 | self.accept('control-mouse1', self.SetButton, [0, True]) 30 | self.accept('mouse1', self.SetButton, [0, True]) 31 | self.accept('mouse1-up', self.SetButton, [0, False]) 32 | 33 | self.accept('alt-mouse2', self.SetButton, [1, True]) 34 | self.accept('control-mouse2', self.SetButton, [1, True]) 35 | self.accept('mouse2', self.SetButton, [1, True]) 36 | self.accept('mouse2-up', self.SetButton, [1, False]) 37 | 38 | self.accept('alt-mouse3', self.SetButton, [2, True]) 39 | self.accept('control-mouse3', self.SetButton, [2, True]) 40 | self.accept('mouse3', self.SetButton, [2, True]) 41 | self.accept('mouse3-up', self.SetButton, [2, False]) 42 | 43 | def OnUpdate(self, task): 44 | super().OnUpdate(task) 45 | 46 | # Get pointer from screen, calculate delta 47 | mp = self.win.getPointer(0) 48 | self.dx = self.x - mp.getX() 49 | self.dy = self.y - mp.getY() 50 | self.x = mp.getX() 51 | self.y = mp.getY() 52 | 53 | def SetModifier(self, modifier): 54 | 55 | # Record modifier 56 | if modifier not in self.modifiers: 57 | self.modifiers.append(modifier) 58 | 59 | def ClearModifier(self, modifier): 60 | 61 | # Remove modifier 62 | if modifier in self.modifiers: 63 | self.modifiers.remove(modifier) 64 | 65 | def SetButton(self, id, value): 66 | 67 | # Record button value 68 | self.buttons[id] = value -------------------------------------------------------------------------------- /src/p3d/mousePicker.py: -------------------------------------------------------------------------------- 1 | from panda3d.core import CollisionTraverser, CollisionHandlerQueue, BitMask32 2 | from panda3d.core import CollisionNode, CollisionRay 3 | 4 | from p3d.singleTask import SingleTask 5 | 6 | 7 | class MousePicker(SingleTask): 8 | 9 | """ 10 | Class to represent a ray fired from the input camera lens using the mouse. 11 | """ 12 | 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | 16 | self.fromCollideMask = kwargs.pop('fromCollideMask', None) 17 | 18 | self.node = None 19 | self.collEntry = None 20 | 21 | # Create collision nodes 22 | self.collTrav = CollisionTraverser() 23 | #self.collTrav.showCollisions(render) 24 | self.collHandler = CollisionHandlerQueue() 25 | self.pickerRay = CollisionRay() 26 | 27 | # Create collision ray 28 | pickerNode = CollisionNode(self.name) 29 | pickerNode.addSolid(self.pickerRay) 30 | pickerNode.setIntoCollideMask(BitMask32.allOff()) 31 | pickerNp = self.camera.attachNewNode(pickerNode) 32 | self.collTrav.addCollider(pickerNp, self.collHandler) 33 | 34 | # Create collision mask for the ray if one is specified 35 | if self.fromCollideMask is not None: 36 | pickerNode.setFromCollideMask(self.fromCollideMask) 37 | 38 | # Bind mouse button events 39 | eventNames = ['mouse1', 'control-mouse1', 'mouse1-up'] 40 | for eventName in eventNames: 41 | self.accept(eventName, self.FireEvent, [eventName]) 42 | 43 | def OnUpdate(self, task, x=None, y=None): 44 | 45 | # Update the ray's position 46 | if self.mouseWatcherNode.hasMouse(): 47 | mp = self.mouseWatcherNode.getMouse() 48 | x, y = mp.getX(), mp.getY() 49 | if x is None or y is None: 50 | return 51 | self.pickerRay.setFromLens(self.camera.node(), x, y) 52 | 53 | # Traverse the hierarchy and find collisions 54 | self.collTrav.traverse(self.rootNp) 55 | if self.collHandler.getNumEntries(): 56 | 57 | # If we have hit something, sort the hits so that the closest is first 58 | self.collHandler.sortEntries() 59 | collEntry = self.collHandler.getEntry(0) 60 | node = collEntry.getIntoNode() 61 | 62 | # If this node is different to the last node, send a mouse leave 63 | # event to the last node, and a mouse enter to the new node 64 | if node != self.node: 65 | if self.node is not None: 66 | messenger.send('%s-mouse-leave' % self.node.getName(), [self.collEntry]) 67 | messenger.send('%s-mouse-enter' % node.getName(), [collEntry]) 68 | 69 | # Send a message containing the node name and the event over name, 70 | # including the collision entry as arguments 71 | messenger.send('%s-mouse-over' % node.getName(), [collEntry]) 72 | 73 | # Keep these values 74 | self.collEntry = collEntry 75 | self.node = node 76 | 77 | elif self.node is not None: 78 | 79 | # No collisions, clear the node and send a mouse leave to the last 80 | # node that stored 81 | messenger.send('%s-mouse-leave' % self.node.getName(), [self.collEntry]) 82 | self.node = None 83 | 84 | def FireEvent(self, event): 85 | """ 86 | Send a message containing the node name and the event name, including 87 | the collision entry as arguments. 88 | """ 89 | if self.node is not None: 90 | messenger.send('%s-%s' % (self.node.getName(), event), [self.collEntry]) 91 | 92 | def GetFirstNodePath(self): 93 | """ 94 | Return the first node in the collision queue if there is one, None 95 | otherwise. 96 | """ 97 | if self.collHandler.getNumEntries(): 98 | collEntry = self.collHandler.getEntry(0) 99 | return collEntry.getIntoNodePath() 100 | 101 | return None -------------------------------------------------------------------------------- /src/p3d/nodePathObject.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class NodePathObject: 8 | 9 | """ 10 | Basic building block class, designed to be attached to a node path in the 11 | scene graph. Doing this creates a circular reference, so care must be 12 | taken to clear the python tag on this class' node path before trying to 13 | remove it. 14 | """ 15 | 16 | pyTagName = 'NodePathObject' 17 | 18 | def __init__(self, np=None): 19 | self.np = None 20 | 21 | if np is not None: 22 | self.Attach(np) 23 | 24 | def __del__(self): 25 | logger.info(self.pyTagName, ' : ', self.np.getName(), ' DELETED') 26 | 27 | def Attach(self, np): 28 | self.np = np 29 | self.np.setPythonTag(self.pyTagName, self) 30 | 31 | @classmethod 32 | def Get(cls, np): 33 | return np.getPythonTag(cls.pyTagName) 34 | 35 | @classmethod 36 | def Break(cls, np): 37 | np.clearPythonTag(cls.pyTagName) -------------------------------------------------------------------------------- /src/p3d/object.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | from direct.showbase.DirectObject import DirectObject 3 | 4 | 5 | class Object(DirectObject): 6 | 7 | def __init__(self, *args, **kwargs): 8 | super().__init__() 9 | 10 | self.camera = kwargs.pop('camera', get_base().camera) 11 | self.rootNp = kwargs.pop('rootNp', get_base().render) 12 | self.root2d = kwargs.pop('root2d', get_base().render2d) 13 | self.rootA2d = kwargs.pop('rootA2d', get_base().aspect2d) 14 | self.rootP2d = kwargs.pop('rootP2d', get_base().pixel2d) 15 | self.win = kwargs.pop('win', get_base().win) 16 | self.mouseWatcherNode = kwargs.pop( 17 | 'mouseWatcherNode', 18 | get_base().mouseWatcherNode 19 | ) 20 | -------------------------------------------------------------------------------- /src/p3d/pandaBehaviour.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from p3d.singleTask import SingleTask 4 | from p3d.pandaManager import PandaManager as pMgr 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class PandaBehaviour(SingleTask): 11 | 12 | cType = 'Script' 13 | 14 | def __init__(self, *args, **kwargs): 15 | SingleTask.__init__(self, *args, **kwargs) 16 | 17 | self.accept(pMgr.PANDA_BEHAVIOUR_INIT, self.Init) 18 | self.accept(pMgr.PANDA_BEHAVIOUR_START, self.Start) 19 | self.accept(pMgr.PANDA_BEHAVIOUR_STOP, self.Stop) 20 | self.accept(pMgr.PANDA_BEHAVIOUR_DEL, self.Del) 21 | 22 | def __del__(self): 23 | logger.info(' PandaBehaviour: ', self.name, ' DELETED') 24 | 25 | def OnInit(self): 26 | pass 27 | 28 | def Init(self): 29 | self.OnInit() 30 | 31 | def OnDel(self): 32 | pass 33 | 34 | def Del(self): 35 | self.OnDel() 36 | -------------------------------------------------------------------------------- /src/p3d/pandaManager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import weakref 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class PandaManager: 10 | 11 | PANDA_BEHAVIOUR_INIT = 'PandaBehaviourInit' 12 | PANDA_BEHAVIOUR_START = 'PandaBehaviourStart' 13 | PANDA_BEHAVIOUR_STOP = 'PandaBehaviourStop' 14 | PANDA_BEHAVIOUR_DEL = 'PandaBehaviourDel' 15 | 16 | def __init__(self): 17 | 18 | if not hasattr(self, 'initialised'): 19 | self.pObjs = {} 20 | 21 | # Set initialise flag 22 | self.initialised = True 23 | 24 | def Init(self): 25 | messenger.send(self.PANDA_BEHAVIOUR_INIT) 26 | 27 | def Start(self): 28 | messenger.send(self.PANDA_BEHAVIOUR_START) 29 | 30 | def Stop(self): 31 | messenger.send(self.PANDA_BEHAVIOUR_STOP) 32 | 33 | def Del(self): 34 | messenger.send(self.PANDA_BEHAVIOUR_DEL) 35 | 36 | def RegisterScript(self, filePath, pObj): 37 | """ 38 | Register the script and the instance. Make sure to register the .py 39 | file, not a .pyo or .pyc file. 40 | """ 41 | filePath = os.path.splitext(filePath)[0]# + '.py' 42 | self.pObjs.setdefault(filePath, weakref.WeakSet([])) 43 | self.pObjs[filePath].add(pObj) 44 | 45 | def DeregisterScript(self, scriptPath): 46 | filePath = os.path.splitext(scriptPath)[0] 47 | if filePath in self.pObjs: 48 | logger.info('degister: ', filePath) 49 | del self.pObjs[filePath] 50 | else: 51 | logger.info('couldnt find: ', filePath) 52 | 53 | def ReloadScripts(self, scriptPaths): 54 | """ 55 | Reload the scripts at the indicated file paths. This means reloading 56 | the code and also recreating any objects that were attached to node 57 | paths in the scene. 58 | """ 59 | scriptPaths = set(scriptPaths) & set(self.pObjs.keys()) 60 | for scriptPath in scriptPaths: 61 | logger.info('Reloading script: ', scriptPath) 62 | for pObj in self.pObjs[scriptPath]: 63 | pObjWrpr = base.node_manager.wrap(pObj) 64 | pObjWrpr.ReloadScript(scriptPath) -------------------------------------------------------------------------------- /src/p3d/pandaObject.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | import logging 4 | 5 | from direct.showbase.DirectObject import DirectObject 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | TAG_PANDA_OBJECT = 'PandaObject' 12 | 13 | 14 | class PandaObject(object): 15 | """ 16 | Basic building block class, designed to be attached to a node path in the 17 | scene graph. Doing this creates a circular reference, so care must be 18 | taken to clear the python tag on this class' node path before trying to 19 | remove it. 20 | """ 21 | def __init__(self): 22 | self.instances = {} 23 | 24 | def __del__(self): 25 | logger.info(TAG_PANDA_OBJECT, ' : ', self.np.getName(), ' DELETED') 26 | 27 | def AttachToNodePath(self, np): 28 | self.np = np 29 | self.np.setPythonTag(TAG_PANDA_OBJECT, self) 30 | 31 | @staticmethod 32 | def Get(np): 33 | 34 | # Return the panda object for the supplied node path 35 | return np.getPythonTag(TAG_PANDA_OBJECT) 36 | 37 | @staticmethod 38 | def Break(np): 39 | 40 | # Detach each script from the object 41 | pObj = PandaObject.Get(np) 42 | if pObj is not None and hasattr(pObj, 'instances'): 43 | for clsName in pObj.instances.keys(): 44 | pObj.DetachScript(clsName) 45 | 46 | # Clear the panda object tag to allow for proper garbage collection 47 | np.clearPythonTag(TAG_PANDA_OBJECT) 48 | 49 | @classmethod 50 | def Duplicate(cls, np): 51 | 52 | # Get the panda object for the input node path 53 | pObj = np.getPythonTag(TAG_PANDA_OBJECT) 54 | if pObj is None: 55 | return None 56 | 57 | # Duplicate the panda object then iterate over the attached instances 58 | # and recreate them on the duplicate 59 | dupePObj = cls(np) 60 | for instance in pObj.instances.values(): 61 | instance = dupePObj.AttachScript(inspect.getfile(instance.__class__)) 62 | clsName = instance.__class__.__name__ 63 | 64 | # Copy property values over 65 | for propName in pObj.GetInstanceProperties(instance): 66 | value = getattr(instance, propName) 67 | setattr(dupePObj.instances[clsName], propName, value) 68 | 69 | return dupePObj 70 | 71 | def DetachScript(self, clsName): 72 | 73 | # Remove an instance from the instance dictionary by its class name. 74 | # Make sure to call ignoreAll() on all instances attached to this 75 | # object which inherit from DirectObject or else they won't be 76 | # deleted properly. 77 | if clsName in self.instances: 78 | instance = self.instances[clsName] 79 | if isinstance(instance, DirectObject): 80 | instance.ignoreAll() 81 | del self.instances[clsName] 82 | 83 | def ReloadScript(self, scriptPath): 84 | 85 | # Get the class name 86 | head, tail = os.path.split(scriptPath) 87 | name = os.path.splitext(tail)[0] 88 | clsName = name[0].upper() + name[1:] 89 | 90 | # Get the old instance 91 | oldInst = self.instances[clsName] 92 | 93 | # Remove the old object and recreate it 94 | self.DetachScript(clsName) 95 | self.AttachScript(scriptPath) 96 | 97 | # Go through the old instance properties and set them on the 98 | # new instance 99 | newInst = self.instances[clsName] 100 | for pName, pType in self.GetInstanceProperties(oldInst).items(): 101 | pValue = getattr(oldInst, pName) 102 | setattr(newInst, pName, pValue) 103 | 104 | def GetInstanceProperties(self, instance): 105 | 106 | # Return a dictionary with keys being the name of the property, and 107 | # values the actual property 108 | props = {} 109 | 110 | for propName, prop in vars(instance.__class__).items(): 111 | if type(prop) == type: 112 | props[propName] = prop 113 | 114 | return props -------------------------------------------------------------------------------- /src/p3d/singleTask.py: -------------------------------------------------------------------------------- 1 | from .object import Object 2 | from direct.task import Task 3 | 4 | 5 | class SingleTask(Object): 6 | 7 | def __init__(self, name, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | 10 | self.name = name 11 | self._task = None 12 | 13 | def OnUpdate(self, task): 14 | """Override this function with code to be executed each frame.""" 15 | pass 16 | 17 | def Update(self, task): 18 | """ 19 | Run OnUpdate method - return task.cont if there was no return value. 20 | """ 21 | result = self.OnUpdate(task) 22 | if result is None: 23 | return task.cont 24 | 25 | return result 26 | 27 | def OnStart(self): 28 | """ 29 | Override this function with code to be executed when the object is 30 | started. 31 | """ 32 | pass 33 | 34 | def Start(self, sort=None, priority=None, delayTime=0): 35 | """Start the object's task if it hasn't been already.""" 36 | # Run OnStart method 37 | self.OnStart() 38 | 39 | if self._task not in taskMgr.getAllTasks(): 40 | if not delayTime: 41 | self._task = taskMgr.add(self.Update, '%sUpdate' % self.name, sort=sort, priority=priority) 42 | else: 43 | self._task = taskMgr.doMethodLater(delayTime, self.Update, '%sUpdate' % self.name, sort=sort, priority=priority) 44 | 45 | def OnStop(self): 46 | """ 47 | Override this function with code to be executed when the object is 48 | stopped. 49 | """ 50 | pass 51 | 52 | def Stop(self): 53 | """Remove the object's task from the task manager.""" 54 | # Run OnStop method 55 | self.OnStop() 56 | 57 | if self._task in taskMgr.getAllTasks(): 58 | taskMgr.remove(self._task) 59 | self._task = None 60 | 61 | def IsRunning(self): 62 | """ 63 | Return True if the object's task can be found in the task manager, 64 | False otherwise. 65 | """ 66 | return self._task in taskMgr.getAllTasks() 67 | -------------------------------------------------------------------------------- /src/p3d/wxPanda.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from panda3d.core import WindowProperties 3 | 4 | 5 | keyCodes = { 6 | wx.WXK_SPACE: 'space', 7 | wx.WXK_DELETE: 'del', 8 | wx.WXK_ESCAPE: 'escape', 9 | wx.WXK_BACK: 'backspace', 10 | wx.WXK_CONTROL: 'control', 11 | wx.WXK_ALT: 'alt', 12 | wx.WXK_UP: 'arrow_up', 13 | wx.WXK_DOWN: 'arrow_down', 14 | wx.WXK_LEFT: 'arrow_left', 15 | wx.WXK_RIGHT: 'arrow_right' 16 | } 17 | 18 | 19 | def OnKey(evt, action=''): 20 | """ 21 | Bind this method to a wx.EVT_KEY_XXX event coming from a wx panel or other 22 | widget, and it will stop wx 'eating' the event and passing it to Panda's 23 | base class instead. 24 | """ 25 | keyCode = evt.GetKeyCode() 26 | if keyCode in keyCodes: 27 | messenger.send(keyCodes[keyCode] + action) 28 | elif keyCode in range(256): 29 | 30 | # Test for any other modifiers. Add these in the order shift, control, 31 | # alt 32 | mod = '' 33 | if evt.ShiftDown(): 34 | mod += 'shift-' 35 | if evt.ControlDown(): 36 | mod += 'control-' 37 | if evt.AltDown(): 38 | mod += 'alt-' 39 | char = chr(keyCode).lower() 40 | messenger.send(mod + char + action) 41 | 42 | 43 | def OnKeyUp(evt): 44 | OnKey(evt, '-up') 45 | 46 | 47 | def OnKeyDown(evt): 48 | OnKey(evt) 49 | 50 | 51 | def OnLeftUp(evt): 52 | messenger.send('mouse1-up') 53 | 54 | 55 | class Viewport(wx.Panel): 56 | 57 | def __init__(self, base, *args, **kwargs): 58 | """ 59 | Initialise the wx panel. You must complete the other part of the 60 | init process by calling Initialize() once the wx-window has been 61 | built. 62 | """ 63 | super().__init__(*args, **kwargs) 64 | 65 | self.base = base 66 | self._win = None 67 | 68 | def Initialize(self, useMainWin=True): 69 | """ 70 | The panda3d window must be put into the wx-window after it has been 71 | shown, or it will not size correctly. 72 | """ 73 | assert self.GetHandle() != 0 74 | wp = WindowProperties() 75 | wp.setOrigin(0, 0) 76 | wp.setSize(self.ClientSize.GetWidth(), self.ClientSize.GetHeight()) 77 | wp.setParentWindow(self.GetHandle()) 78 | if self._win is None: 79 | if useMainWin: 80 | self.base.openDefaultWindow(props=wp, gsg=None) 81 | self._win = self.base.win 82 | else: 83 | self._win = self.base.openWindow(props=wp, makeCamera=0) 84 | self.Bind(wx.EVT_SIZE, self.OnResize) 85 | 86 | def OnResize(self, event): 87 | """When the wx-panel is resized, fit the panda3d window into it.""" 88 | frame_size = event.GetSize() 89 | wp = WindowProperties() 90 | wp.setOrigin(0, 0) 91 | wp.setSize(frame_size.GetWidth(), frame_size.GetHeight()) 92 | self._win.requestProperties(wp) 93 | -------------------------------------------------------------------------------- /src/pandaEditor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/constants.py: -------------------------------------------------------------------------------- 1 | MODEL_EXTENSIONS = ( 2 | '.egg', 3 | '.bam', 4 | '.pz', 5 | '.obj', 6 | ) 7 | PANDA_3D_RUNTIME_PATH = r'C:\Panda3D-1.10.9-x64' -------------------------------------------------------------------------------- /src/pandaEditor/directorywatcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import threading 4 | 5 | 6 | class DirectoryWatcher(threading.Thread): 7 | 8 | """ 9 | Class for watching a directory and all subdirectories below it for 10 | changes. 11 | 12 | """ 13 | 14 | def __init__(self, root=False): 15 | super().__init__() 16 | 17 | self.root = root 18 | 19 | self.daemon = True 20 | self.running = False 21 | 22 | def _recurse(self, dirPath): 23 | """ 24 | 25 | """ 26 | fDict = {} 27 | 28 | def setDict(key): 29 | fDict[key] = os.path.getmtime(key) 30 | 31 | if self.root: 32 | noval = [ 33 | ([setDict(os.path.join(path, f)) for f in files if files], 34 | setDict(path)) 35 | for path, dirs, files in os.walk(dirPath, True) 36 | ] 37 | else: 38 | noval = [ 39 | ([setDict(os.path.join(path, f)) for f in files if files], 40 | [setDict(os.path.join(path, f)) for f in dirs if dirs]) 41 | for path, dirs, files in os.walk(dirPath, True) 42 | ] 43 | 44 | return fDict 45 | 46 | def setDirectory(self, dirPath): 47 | """Set the directory for watching.""" 48 | self.dirPath = dirPath 49 | self.before = self._recurse(self.dirPath) 50 | 51 | def run(self): 52 | """ 53 | Main watcher function. Don't use this to start the watcher, use 54 | start() to run the daemon instead. 55 | """ 56 | self.running = True 57 | while True: 58 | after = self._recurse(self.dirPath) 59 | 60 | # Work out which files were added, removed or modified 61 | added = [f for f in after if not f in self.before] 62 | removed = [f for f in self.before if not f in after] 63 | modified = [ 64 | f for f in after 65 | if f in self.before and after[f] != self.before[f] 66 | ] 67 | 68 | # Call handlers 69 | if added: 70 | self.onAdded(added) 71 | if removed: 72 | self.onRemoved(removed) 73 | if modified: 74 | self.onModified(modified) 75 | 76 | self.before = after 77 | 78 | # Sleep a bit so we don't max out the thread 79 | time.sleep(0.5) 80 | 81 | def onAdded(self, filePaths): 82 | pass 83 | 84 | def onRemoved(self, filePaths): 85 | pass 86 | 87 | def onModified(self, filePaths): 88 | pass 89 | -------------------------------------------------------------------------------- /src/pandaEditor/dragdropmanager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import wx 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class DragDropManager: 10 | 11 | def start(self, ctrl, data): 12 | logging.info(f'Start drag drop: {data}') 13 | 14 | self.data = data 15 | do = wx.CustomDataObject('data') 16 | ds = wx.DropSource(ctrl) 17 | ds.SetData(do) 18 | ds.DoDragDrop(True) 19 | -------------------------------------------------------------------------------- /src/pandaEditor/dragdroptarget.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | import wx 4 | 5 | from direct.showbase.PythonUtil import getBase as get_base 6 | 7 | 8 | class DragDropTarget(wx.DropTarget): 9 | 10 | def __init__(self, validate_fn, drop_fn): 11 | super().__init__() 12 | 13 | self.validate_fn = validate_fn 14 | self.drop_fn = drop_fn 15 | do = wx.CustomDataObject('data') 16 | self.SetDataObject(do) 17 | 18 | def OnDragOver(self, x, y, d): 19 | data = get_base().drag_drop_manager.data 20 | return d if self.validate_fn(x, y, data) else wx.DragNone 21 | 22 | def OnData(self, x, y, d): 23 | data = get_base().drag_drop_manager.data 24 | if not data: 25 | return 26 | self.drop_fn(x, y, data) 27 | return d 28 | -------------------------------------------------------------------------------- /src/pandaEditor/game/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/game/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/game/nodes/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/actor.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | from direct.actor.Actor import Actor as P3dActor 3 | 4 | from game.nodes.constants import ( 5 | TAG_ACTOR, TAG_MODEL_PATH, TAG_NODE_TYPE, TAG_NODE_UUID 6 | ) 7 | from game.nodes.modelroot import ModelRoot 8 | 9 | 10 | class Actor(ModelRoot): 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | 15 | self.AddAttributes( 16 | PyTagAttribute( 17 | 'Anims', 18 | dict, 19 | self.GetAnimDict, 20 | self.SetAnimDict, 21 | pyTagName=TAG_ACTOR 22 | ), 23 | parent='Actor' 24 | ) 25 | 26 | @classmethod 27 | def create(cls, *args, **kwargs): 28 | wrpr = super(Actor, cls).create(*args, **kwargs) 29 | 30 | actor = P3dActor(wrpr.data) 31 | actor.setTag(TAG_NODE_TYPE, 'Actor') 32 | actor.setTag(TAG_NODE_UUID, wrpr.data.getTag(TAG_NODE_UUID)) 33 | actor.setPythonTag(TAG_MODEL_PATH, str(wrpr.data.node().getFullpath())) 34 | actor.setPythonTag(TAG_ACTOR, actor) 35 | 36 | return cls(actor.getGeomNode()) 37 | 38 | def duplicate(self): 39 | dupeNp = ModelRoot.duplicate(self) 40 | 41 | actor = P3dActor(dupeNp) 42 | actor.setTag(TAG_NODE_TYPE, 'Actor') 43 | actor.setPythonTag(TAG_ACTOR, actor) 44 | actor.reparentTo(self.data.getParent()) 45 | actor.setTransform(dupeNp.getTransform()) 46 | actor.setName(dupeNp.getName()) 47 | 48 | dupeNp.detachNode() 49 | 50 | # Copy animations over to the new actor. 51 | oldAnims = self.GetAnimDict(self.data.getPythonTag(TAG_ACTOR)) 52 | self.SetAnimDict(actor, oldAnims) 53 | 54 | return actor.getGeomNode() 55 | 56 | def GetAnimDict(self, actor): 57 | animDict = {} 58 | for name in P3dActor.getAnimNames(actor): 59 | filePath = actor.getAnimFilename(name) 60 | animDict[name] = base.project.get_rel_model_path(filePath) 61 | 62 | return animDict 63 | 64 | def SetAnimDict(self, actor, animDict): 65 | actor.removeAnimControlDict() 66 | 67 | myDict = {} 68 | for key, value in animDict.items(): 69 | try: 70 | pandaPath = pm.Filename.fromOsSpecific(value) 71 | except TypeError: 72 | pandaPath = value 73 | myDict[key] = pandaPath 74 | 75 | actor.loadAnims(myDict) 76 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import copy 3 | import logging 4 | import uuid 5 | 6 | from direct.showbase.PythonUtil import getBase as get_base 7 | 8 | from game.nodes.componentmetaclass import ComponentMetaClass 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Base(metaclass=ComponentMetaClass): 15 | 16 | def __init__(self, data): 17 | self.data = data 18 | self._children = [] 19 | 20 | @classmethod 21 | def create(cls, *args, **kwargs): 22 | data = kwargs.pop('data', None) 23 | if data is None: 24 | data = cls.type_(**kwargs) 25 | comp = cls(data) 26 | return comp 27 | 28 | def __hash__(self): 29 | return hash(self.data) 30 | 31 | def __eq__(self, other): 32 | return hash(self) == hash(other) 33 | 34 | @property 35 | def id(self): 36 | return self.metaobject.id 37 | 38 | @id.setter 39 | def id(self, value): 40 | self.metaobject.id = value 41 | 42 | @property 43 | def type(self): 44 | return type(self).__name__ 45 | 46 | @property 47 | def parent(self): 48 | 49 | # Return None if the component isn't registered - it may have been 50 | # detached. 51 | if self.data not in get_base().scene.objects: 52 | return None 53 | return get_base().node_manager.wrap(get_base().scene) 54 | 55 | @parent.setter 56 | def parent(self, value): 57 | value.add_child(self) 58 | 59 | @property 60 | def children(self): 61 | return self._children 62 | 63 | @abc.abstractmethod 64 | def detach(self): 65 | """""" 66 | 67 | @abc.abstractmethod 68 | def destroy(self): 69 | """""" 70 | 71 | @abc.abstractmethod 72 | def add_child(self, child): 73 | """""" 74 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/basemetaclass.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | from importlib import import_module 4 | 5 | from panda3d.core import ConfigVariableBool 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class BaseMetaClass(type): 12 | 13 | def __new__(metacls, name, bases, attrs): 14 | 15 | # Run once per class only. Will run for every new component selection 16 | # because the scene graph is dynamically creating types. 17 | cls = super().__new__(metacls, name, bases, attrs) 18 | if not hasattr(cls, 'change_mro'): 19 | cls.change_mro = True 20 | cls.__bases__ = cls.__bases__ + tuple() 21 | return cls 22 | 23 | def mro(cls): 24 | """ 25 | Called every time class.mro() is called - NOT once during class 26 | creation. Don't call mro() in your own code, use __mro__ instead. 27 | 28 | """ 29 | change_mro = bool( 30 | getattr(cls, 'change_mro', False) and 31 | ConfigVariableBool('editor_mode', False) 32 | ) 33 | mro = super().mro() 34 | 35 | # TODO: Might want to try and cache the results here so we don't have 36 | # to run this method every time mro() is called. 37 | if change_mro: 38 | return cls.get_mro(cls, mro) 39 | return mro 40 | 41 | @classmethod 42 | def get_mro(metacls, cls, mro): 43 | class_name = cls.__name__ 44 | path = cls.__module__.split('.') 45 | 46 | # Don't attempt to mix in anything but game classes. 47 | if path[0] != 'game': 48 | logger.info(f'Ignoring mixin for class: {".".join(path)}.{class_name}') 49 | return mro 50 | path[0] = 'pandaEditor' 51 | search_path = '.'.join(path) 52 | 53 | try: 54 | module = import_module(search_path) 55 | except ModuleNotFoundError as e: 56 | 57 | # TODO: Set a flag here so we're not continually trying to 58 | # load a module that's not there. 59 | logger.warning(f'Editor module not found: {search_path}') 60 | return mro 61 | 62 | editor_cls = next(iter([ 63 | value 64 | for name, value in inspect.getmembers(module, inspect.isclass) 65 | if name == class_name 66 | ]), None) 67 | if editor_cls is None: 68 | logger.info(f'Could not find editor class: {class_name}') 69 | return mro 70 | 71 | # Ignore the last mro "object" as it's common to both. 72 | mro = list(editor_cls.__mro__[0:-1]) + mro 73 | names = ', '.join([c.__name__ for c in mro]) 74 | logger.info(f'Component: {cls.__name__} mro: {names}') 75 | return mro 76 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/bullet.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | import panda3d.bullet as pb 3 | from direct.showbase.PythonUtil import getBase as get_base 4 | 5 | from game.nodes.attributes import ( 6 | Attribute, 7 | Connection, 8 | Connections, 9 | PythonTagAttribute, 10 | TagAttribute, 11 | MetaobjectTagAttribute, 12 | ) 13 | from game.nodes.nodepath import NodePath 14 | from game.nodes.nongraphobject import NonGraphObject 15 | 16 | 17 | class BulletBoxShape(NonGraphObject): 18 | 19 | type_ = pb.BulletBoxShape 20 | halfExtents = Attribute( 21 | pc.Vec3, 22 | pb.BulletBoxShape.get_half_extents_with_margin, 23 | required=True, 24 | ) 25 | 26 | 27 | class BulletCapsuleShape(NonGraphObject): 28 | 29 | type_ = pb.BulletCapsuleShape 30 | radius = Attribute(float, pb.BulletCapsuleShape.get_radius, required=True) 31 | height = Attribute(float, pb.BulletCapsuleShape.get_half_height, required=True) 32 | up = MetaobjectTagAttribute(int, read_only=True, required=True) 33 | 34 | 35 | class BulletDebugNode(NodePath): 36 | 37 | type_ = pb.BulletDebugNode 38 | 39 | 40 | class BulletPlaneShape(NonGraphObject): 41 | 42 | type_ = pb.BulletPlaneShape 43 | normal = MetaobjectTagAttribute(pc.Vec3, read_only=True, required=True) 44 | point = MetaobjectTagAttribute(pc.Vec3, read_only=True, required=True) 45 | 46 | @classmethod 47 | def create(cls, *args, **kwargs): 48 | plane = pc.Plane(kwargs['normal'], kwargs['point']) 49 | return super().create(plane=plane) 50 | 51 | 52 | def clear_shapes(obj): 53 | num_shapes = obj.get_num_shapes() 54 | for i in range(num_shapes): 55 | obj.remove_shape(obj.get_shape(0)) 56 | 57 | 58 | class BulletRigidBodyNode(NodePath): 59 | 60 | type_ = pb.BulletRigidBodyNode 61 | angular_dampening = Attribute( 62 | float, 63 | pb.BulletRigidBodyNode.getAngularDamping, 64 | pb.BulletRigidBodyNode.setAngularDamping, 65 | node_data=True, 66 | ) 67 | gravity = Attribute( 68 | pc.Vec3, 69 | pb.BulletRigidBodyNode.getGravity, 70 | pb.BulletRigidBodyNode.setGravity, 71 | node_data=True, 72 | ) 73 | mass = Attribute( 74 | float, 75 | pb.BulletRigidBodyNode.get_mass, 76 | pb.BulletRigidBodyNode.set_mass, 77 | node_data=True, 78 | ) 79 | shapes = Connections( 80 | pb.BulletShape, 81 | pb.BulletRigidBodyNode.get_shapes, 82 | pb.BulletRigidBodyNode.add_shape, 83 | clear_shapes, 84 | node_data=True, 85 | ) 86 | 87 | 88 | class BulletSphereShape(NonGraphObject): 89 | 90 | type_ = pb.BulletSphereShape 91 | radius = Attribute( 92 | float, 93 | pb.BulletSphereShape.get_radius, 94 | required=True, 95 | ) 96 | 97 | 98 | def clear_rigid_bodies(obj): 99 | for i in range(obj.get_num_rigid_bodies()): 100 | obj.remove(obj.get_rigid_body(0)) 101 | 102 | 103 | class BulletWorld(NonGraphObject): 104 | 105 | type_ = pb.BulletWorld 106 | gravity = Attribute( 107 | pc.Vec3, 108 | pb.BulletWorld.get_gravity, 109 | pb.BulletWorld.set_gravity, 110 | ) 111 | debug_node = Connection( 112 | pb.BulletDebugNode, 113 | pb.BulletWorld.get_debug_node, 114 | pb.BulletWorld.set_debug_node, 115 | pb.BulletWorld.clear_debug_node, 116 | node_target=True, 117 | ) 118 | rigid_bodies = Connections( 119 | pb.BulletRigidBodyNode, 120 | pb.BulletWorld.get_rigid_bodies, 121 | pb.BulletWorld.attach, 122 | clear_rigid_bodies, 123 | node_target=True, 124 | ) 125 | 126 | def destroy(self): 127 | if ( 128 | get_base().scene.physics_world is self.data and 129 | get_base().scene.physics_task in get_base().task_mgr.getAllTasks() 130 | ): 131 | self.disable_physics() 132 | 133 | def enable_physics(self): 134 | 135 | def update(task): 136 | dt = globalClock.getDt() 137 | self.data.doPhysics(dt) 138 | return task.cont 139 | 140 | get_base().scene.physics_task = get_base().task_mgr.add(update, 'update') 141 | 142 | def disable_physics(self): 143 | get_base().task_mgr.remove(get_base().scene.physics_task) 144 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/camera.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | 3 | from game.nodes.lensnode import LensNode 4 | from game.nodes.componentmetaclass import ComponentMetaClass 5 | 6 | 7 | class Camera(LensNode, metaclass=ComponentMetaClass): 8 | 9 | type_ = pm.Camera 10 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/collision.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import Attribute, Connections 4 | from game.nodes.nodepath import NodePath 5 | from game.nodes.nongraphobject import NonGraphObject 6 | 7 | 8 | class CollisionBox(NonGraphObject): 9 | 10 | type_ = pc.CollisionBox 11 | min = Attribute( 12 | pc.Point3, 13 | pc.CollisionBox.get_min, 14 | required=True, 15 | ) 16 | max = Attribute( 17 | pc.Point3, 18 | pc.CollisionBox.get_max, 19 | required=True, 20 | ) 21 | 22 | 23 | class CollisionCapsule(NonGraphObject): 24 | 25 | type_ = pc.CollisionCapsule 26 | a = Attribute( 27 | pc.Point3, 28 | pc.CollisionCapsule.get_point_a, 29 | pc.CollisionCapsule.set_point_a, 30 | required=True, 31 | ) 32 | db = Attribute( 33 | pc.Point3, 34 | pc.CollisionCapsule.get_point_b, 35 | pc.CollisionCapsule.set_point_b, 36 | required=True, 37 | ) 38 | radius = Attribute( 39 | float, 40 | pc.CollisionCapsule.get_radius, 41 | pc.CollisionCapsule.set_radius, 42 | required=True, 43 | ) 44 | 45 | 46 | class CollisionNode(NodePath): 47 | 48 | type_ = pc.CollisionNode 49 | solids = Connections( 50 | pc.CollisionSolid, 51 | pc.CollisionNode.get_solids, 52 | pc.CollisionNode.add_solid, 53 | pc.CollisionNode.clear_solids, 54 | node_data=True, 55 | ) 56 | 57 | 58 | class CollisionRay(NonGraphObject): 59 | 60 | type_ = pc.CollisionRay 61 | origin = Attribute( 62 | pc.Point3, 63 | pc.CollisionRay.get_origin, 64 | pc.CollisionRay.set_origin, 65 | required=True, 66 | ) 67 | direction = Attribute( 68 | pc.Vec3, 69 | pc.CollisionRay.get_direction, 70 | pc.CollisionRay.set_direction, 71 | required=True, 72 | ) 73 | 74 | 75 | class CollisionSphere(NonGraphObject): 76 | 77 | type_ = pc.CollisionSphere 78 | center = Attribute( 79 | pc.Point3, 80 | pc.CollisionSphere.get_center, 81 | pc.CollisionSphere.set_center, 82 | required=True, 83 | ) 84 | radius = Attribute( 85 | float, 86 | pc.CollisionSphere.get_radius, 87 | pc.CollisionSphere.set_radius, 88 | required=True, 89 | ) 90 | 91 | 92 | class CollisionInvSphere(CollisionSphere): 93 | 94 | type_ = pc.CollisionInvSphere 95 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/componentmetaclass.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from game.nodes.attributes import Base, Attribute, Connection 4 | from game.nodes.basemetaclass import BaseMetaClass 5 | 6 | 7 | class ComponentMetaClass(abc.ABCMeta, BaseMetaClass): 8 | 9 | def __new__(metacls, name, bases, attrs): 10 | cls = super().__new__(metacls, name, bases, attrs) 11 | cls.properties = metacls.get_properties(cls) 12 | 13 | return cls 14 | 15 | def get_properties(cls): 16 | 17 | # TODO: Probably don't have to do entire mro considering the way 18 | # metaclasses work. 19 | results = {} 20 | for base in reversed(cls.__mro__): 21 | for key, value in base.__dict__.items(): 22 | if not isinstance(value, Base): 23 | continue 24 | 25 | # TODO: Need a better way to set category / name data. 26 | value.category = base.__name__ 27 | value.name = key 28 | results[key] = value 29 | return results 30 | 31 | @property 32 | def attributes(cls): 33 | return { 34 | key: value 35 | for key, value in cls.properties.items() 36 | if isinstance(value, Attribute) 37 | } 38 | 39 | @property 40 | def connections(cls): 41 | return { 42 | key: value 43 | for key, value in cls.properties.items() 44 | if isinstance(value, Connection) 45 | } 46 | 47 | @property 48 | def create_attributes(cls): 49 | return { 50 | key: value 51 | for key, value in cls.attributes.items() 52 | if value.required 53 | } 54 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/constants.py: -------------------------------------------------------------------------------- 1 | TAG_NODE_TYPE = 'P3D_Type' 2 | TAG_NODE_UUID = 'P3D_UUID' 3 | TAG_PYTHON_TAGS = 'P3D_PythonTags' 4 | TAG_MODEL_ROOT_CHILD = 'P3D_ModelRootChild' 5 | TAG_ACTOR = 'P3D_Actor' 6 | TAG_MODEL_PATH = 'P3D_ModelPath' -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/fog.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import Attribute 4 | from game.nodes.nodepath import NodePath 5 | 6 | 7 | class Fog(NodePath): 8 | 9 | type_ = pc.Fog 10 | color = Attribute( 11 | pc.Vec4, 12 | pc.Fog.get_color, 13 | pc.Fog.set_color, 14 | node_data=True 15 | ) 16 | linear_onset_point = Attribute( 17 | pc.Point3, 18 | pc.Fog.get_linear_onset_point, 19 | pc.Fog.set_linear_onset_point, 20 | node_data=True, 21 | ) 22 | linear_opaque_point = Attribute( 23 | pc.Point3, 24 | pc.Fog.get_linear_opaque_point, 25 | pc.Fog.set_linear_opaque_point, 26 | node_data=True, 27 | ) 28 | exponential_density = Attribute( 29 | float, 30 | pc.Fog.get_exp_density, 31 | pc.Fog.set_exp_density, 32 | node_data=True, 33 | ) 34 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/lensnode.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | from panda3d.core import Lens 3 | 4 | from game.nodes.attributes import Attribute 5 | from game.nodes.nodepath import NodePath 6 | from game.nodes.componentmetaclass import ComponentMetaClass 7 | 8 | 9 | class LensNodeAttribute(Attribute): 10 | 11 | def _get_data(self, instance): 12 | return super()._get_data(instance).node().get_lens() 13 | 14 | 15 | class LensNode(NodePath, metaclass=ComponentMetaClass): 16 | 17 | type_ = pm.LensNode 18 | fov = LensNodeAttribute(pm.Vec2, Lens.get_fov, Lens.set_fov) 19 | near = LensNodeAttribute(float, Lens.get_near, Lens.set_near) 20 | far = LensNodeAttribute(float, Lens.get_far, Lens.set_far) 21 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/lights.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import Attribute 4 | from game.nodes.lensnode import LensNode 5 | from game.nodes.nodepath import NodePath 6 | 7 | 8 | class Light(NodePath): 9 | 10 | type_ = pc.Light 11 | color = Attribute( 12 | pc.Vec4, 13 | pc.Light.get_color, 14 | pc.Light.set_color, 15 | node_data=True, 16 | ) 17 | 18 | 19 | class AmbientLight(Light): 20 | 21 | type_ = pc.AmbientLight 22 | 23 | 24 | class DirectionalLight(Light): 25 | 26 | type_ = pc.DirectionalLight 27 | direction = Attribute( 28 | pc.Vec3, 29 | pc.DirectionalLight.get_direction, 30 | pc.DirectionalLight.set_direction, 31 | node_data=True, 32 | ) 33 | point = Attribute( 34 | pc.Point3, 35 | pc.DirectionalLight.get_point, 36 | pc.DirectionalLight.set_point, 37 | node_data=True, 38 | ) 39 | specular_colour = Attribute( 40 | pc.Point3, 41 | pc.DirectionalLight.get_specular_color, 42 | pc.DirectionalLight.set_specular_color, 43 | node_data=True, 44 | ) 45 | shadow_caster = Attribute( 46 | bool, 47 | pc.DirectionalLight.is_shadow_caster, 48 | pc.DirectionalLight.set_shadow_caster, 49 | node_data=True, 50 | ) 51 | 52 | 53 | class PointLight(Light): 54 | 55 | type_ = pc.PointLight 56 | attenuation = Attribute( 57 | pc.Vec3, 58 | pc.PointLight.get_attenuation, 59 | pc.PointLight.set_attenuation, 60 | node_data=True, 61 | ) 62 | point = Attribute( 63 | pc.Point3, 64 | pc.PointLight.get_point, 65 | pc.PointLight.set_point, 66 | node_data=True, 67 | ) 68 | specular_color = Attribute( 69 | pc.Vec4, 70 | pc.PointLight.get_specular_color, 71 | pc.PointLight.set_specular_color, 72 | node_data=True, 73 | ) 74 | 75 | 76 | class Spotlight(Light, LensNode): 77 | 78 | type_ = pc.Spotlight 79 | attenuation = Attribute( 80 | pc.Vec3, 81 | pc.Spotlight.get_attenuation, 82 | pc.Spotlight.set_attenuation, 83 | node_data=True, 84 | ) 85 | exponent = Attribute( 86 | float, 87 | pc.Spotlight.get_exponent, 88 | pc.Spotlight.set_exponent, 89 | node_data=True, 90 | ) 91 | specular_color = Attribute( 92 | pc.Vec4, 93 | pc.Spotlight.get_specular_color, 94 | pc.Spotlight.set_specular_color, 95 | node_data=True, 96 | ) 97 | show_caster = Attribute( 98 | bool, 99 | pc.Spotlight.is_shadow_caster, 100 | pc.Spotlight.set_shadow_caster, 101 | node_data=True, 102 | ) 103 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/manager.py: -------------------------------------------------------------------------------- 1 | from game.nodes.actor import Actor 2 | from game.nodes.base import Base 3 | from game.nodes.bullet import ( 4 | BulletBoxShape, 5 | BulletCapsuleShape, 6 | BulletDebugNode, 7 | BulletPlaneShape, 8 | BulletRigidBodyNode, 9 | BulletSphereShape, 10 | BulletWorld, 11 | ) 12 | from game.nodes.camera import Camera 13 | from game.nodes.collision import ( 14 | CollisionBox, 15 | CollisionCapsule, 16 | CollisionInvSphere, 17 | CollisionNode, 18 | CollisionRay, 19 | CollisionSphere, 20 | ) 21 | from game.nodes.constants import TAG_NODE_TYPE 22 | from game.nodes.fog import Fog 23 | from game.nodes.lensnode import LensNode 24 | from game.nodes.lights import ( 25 | AmbientLight, 26 | DirectionalLight, 27 | PointLight, 28 | Spotlight 29 | ) 30 | from game.nodes.modelnode import ModelNode 31 | from game.nodes.modelroot import ModelRoot 32 | from game.nodes.nodepath import NodePath 33 | from game.nodes.nongraphobject import NonGraphObject 34 | from game.nodes.pandanode import PandaNode 35 | from game.nodes.particleeffect import ParticleEffect 36 | from game.nodes.sceneroot import SceneRoot 37 | from game.nodes.showbasedefaults import ( 38 | Aspect2d, 39 | BaseCam, 40 | BaseCamera, 41 | Cam2d, 42 | Camera2d, 43 | Pixel2d, 44 | Render, 45 | Render2d, 46 | ) 47 | from game.nodes.texture import Texture 48 | 49 | 50 | class Manager: 51 | 52 | def __init__(self): 53 | self.wrappers = { 54 | 'Actor': Actor, 55 | 'AmbientLight': AmbientLight, 56 | 'Aspect2d': Aspect2d, 57 | 'Base': Base, 58 | 'BaseCam': BaseCam, 59 | 'BaseCamera': BaseCamera, 60 | 'BulletBoxShape': BulletBoxShape, 61 | 'BulletCapsuleShape': BulletCapsuleShape, 62 | 'BulletDebugNode': BulletDebugNode, 63 | 'BulletPlaneShape': BulletPlaneShape, 64 | 'BulletSphereShape': BulletSphereShape, 65 | 'BulletRigidBodyNode': BulletRigidBodyNode, 66 | 'BulletWorld': BulletWorld, 67 | 'Cam2d': Cam2d, 68 | 'Camera': Camera, 69 | 'Camera2d': Camera2d, 70 | 'CollisionBox': CollisionBox, 71 | 'CollisionCapsule': CollisionCapsule, 72 | 'CollisionInvSphere': CollisionInvSphere, 73 | 'CollisionNode': CollisionNode, 74 | 'CollisionRay': CollisionRay, 75 | 'CollisionSphere': CollisionSphere, 76 | 'DirectionalLight': DirectionalLight, 77 | 'Fog': Fog, 78 | 'LensNode': LensNode, 79 | 'ModelNode': ModelNode, 80 | 'ModelRoot': ModelRoot, 81 | 'NodePath': NodePath, 82 | 'NonGraphObject': NonGraphObject, 83 | 'PandaNode': PandaNode, 84 | 'ParticleEffect': ParticleEffect, 85 | 'Pixel2d': Pixel2d, 86 | 'PointLight': PointLight, 87 | 'Render': Render, 88 | 'Render2d': Render2d, 89 | 'SceneRoot': SceneRoot, 90 | 'Spotlight': Spotlight, 91 | 'Texture': Texture, 92 | } 93 | 94 | def wrap(self, obj): 95 | """ 96 | Return a wrapper suitable for the indicated object. If the correct 97 | wrapper cannot be found, return a NodePath wrapper for NodePaths and 98 | a Base wrapper for everything else. 99 | 100 | """ 101 | comp_cls = self.get_wrapper(obj) 102 | if comp_cls is not None: 103 | return comp_cls(obj) 104 | else: 105 | comp_cls = self.get_default_wrapper(obj) 106 | return comp_cls(obj) 107 | 108 | def get_wrapper(self, obj): 109 | type_ = self.get_type_string(obj) 110 | return self.wrappers.get(type_) 111 | 112 | def get_component_by_name(self, c_type): 113 | return self.wrappers.get(c_type) 114 | 115 | def get_type_string(self, comp): 116 | """ 117 | Return the type of the component as a string. Components are 118 | identified in the following method (in order): 119 | 120 | - If the component has the class variable 'cType' then this string 121 | will be used as the type. 122 | - Use the component's type's name as the type. 123 | - If this is 'NodePath' then look for a overriding tag on the node 124 | for the type. 125 | - If this tag is missing, use the NodePath's node as the type. 126 | """ 127 | if hasattr(comp.__class__, 'cType'): 128 | return comp.cType 129 | 130 | type_str = type(comp).__name__ 131 | if type_str == 'NodePath': 132 | type_str = comp.node().get_tag(TAG_NODE_TYPE) 133 | if not type_str: 134 | type_str = type(comp.node()).__name__ 135 | return type_str 136 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/modelnode.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | 3 | from game.nodes.nodepath import NodePath 4 | from game.nodes.componentmetaclass import ComponentMetaClass 5 | 6 | 7 | class ModelNode(NodePath, metaclass=ComponentMetaClass): 8 | 9 | type_ = pm.ModelNode 10 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/modelroot.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | from direct.showbase.PythonUtil import getBase as get_base 3 | 4 | from game.nodes.attributes import Attribute 5 | from game.nodes.constants import TAG_MODEL_ROOT_CHILD 6 | from game.nodes.nodepath import NodePath 7 | 8 | 9 | class ModelRoot(NodePath): 10 | 11 | type_ = pc.ModelRoot 12 | fullpath = Attribute( 13 | pc.Filename, 14 | pc.ModelRoot.get_fullpath, 15 | required=True, 16 | node_data=True, 17 | ) 18 | 19 | @classmethod 20 | def create(cls, *args, **kwargs): 21 | fullpath = kwargs.pop('fullpath', None) 22 | if fullpath is not None: 23 | panda_fullpath = pc.Filename.from_os_specific(fullpath) 24 | np = get_base().loader.load_model(panda_fullpath) 25 | kwargs['data'] = np 26 | 27 | comp = super().create(*args, **kwargs) 28 | fullpath = comp.data.node().get_fullpath() 29 | comp.data.set_name(fullpath.get_basename_wo_extension()) 30 | 31 | # Iterate over child nodes 32 | # TBH I'm not even sure I know what this does. 33 | # comp.extraNps = [] 34 | # def Recurse(node): 35 | # nTypeStr = node.getTag(TAG_NODE_TYPE) 36 | # cWrprCls = get_base().node_manager.get_component_by_name(nTypeStr) 37 | # if cWrprCls is not None: 38 | # cWrpr = cWrprCls.create(inputNp=node) 39 | # comp.extraNps.append(cWrpr.data) 40 | # 41 | # # Recurse 42 | # for child in node.getChildren(): 43 | # Recurse(child) 44 | # 45 | # Recurse(comp.data) 46 | 47 | return comp 48 | 49 | def add_child(self, child): 50 | """ 51 | Parent the indicated NodePath to the NodePath wrapped by this object. 52 | We don't have to parent NodePaths with the model root tag as they were 53 | created with the correct hierarchy to begin with. 54 | """ 55 | if not child.data.get_python_tag(TAG_MODEL_ROOT_CHILD): 56 | child.data.reparent_to(self.data) 57 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/nodepath.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | from direct.showbase.PythonUtil import getBase as get_base 3 | 4 | from game.nodes.attributes import ( 5 | Attribute, 6 | Connection, 7 | Connections, 8 | ) 9 | from game.nodes.constants import ( 10 | TAG_ACTOR, 11 | TAG_NODE_UUID, 12 | TAG_PYTHON_TAGS 13 | ) 14 | from game.nodes.base import Base 15 | from game.nodes.componentmetaclass import ComponentMetaClass 16 | 17 | 18 | def get_lights(data): 19 | attrib = data.get_attrib(pc.LightAttrib) 20 | return attrib.get_on_lights() if attrib is not None else [] 21 | 22 | 23 | def set_texture(data, value): 24 | pc.NodePath.set_texture(data, value, 1) 25 | 26 | 27 | class NodePath(Base, metaclass=ComponentMetaClass): 28 | 29 | type_ = pc.NodePath 30 | name = Attribute( 31 | str, 32 | pc.NodePath.get_name, 33 | pc.NodePath.set_name, 34 | required=True 35 | ) 36 | color = Attribute( 37 | pc.LColor, 38 | pc.NodePath.get_color, 39 | pc.NodePath.set_color, 40 | ) 41 | color_scale = Attribute( 42 | pc.LColor, 43 | pc.NodePath.get_color_scale, 44 | pc.NodePath.set_color_scale, 45 | ) 46 | matrix = Attribute(pc.Mat4, pc.NodePath.get_mat, pc.NodePath.set_mat) 47 | texture = Connection( 48 | pc.Texture, 49 | pc.NodePath.get_texture, 50 | set_texture, 51 | pc.NodePath.clear_texture, 52 | ) 53 | lights = Connections( 54 | pc.Light, 55 | get_lights, 56 | pc.NodePath.set_light, 57 | pc.NodePath.clear_light, 58 | ) 59 | fog = Connection( 60 | pc.Fog, 61 | pc.NodePath.get_fog, 62 | pc.NodePath.set_fog, 63 | pc.NodePath.clear_fog, 64 | node_target=True, 65 | ) 66 | 67 | @classmethod 68 | def create(cls, *args, **kwargs): 69 | """ 70 | Create a NodePath with the indicated type and name, set it up and 71 | return it. 72 | 73 | """ 74 | # For sub-model root nodepaths. 75 | path = kwargs.pop('path', None) 76 | if path is not None: 77 | return cls(cls.find_child(path, kwargs.pop('parent'))) 78 | 79 | # Sometimes the data being wrapped is already a node path so this 80 | # step is unnecessary. 81 | comp = super().create(*args, **kwargs) 82 | if not isinstance(comp.data, pc.NodePath): 83 | comp.data = pc.NodePath(comp.data) 84 | return comp 85 | 86 | def __hash__(self): 87 | 88 | # An attempt to return an identifier for this node. Note that this is 89 | # apparently not unique amongst nodepaths that point to the same object, 90 | # so if this editor ever supports instancing this might fall over 91 | # spectacularly. 92 | return hash(self.data.get_key()) 93 | 94 | @property 95 | def id(self): 96 | return self.data.get_tag(TAG_NODE_UUID) 97 | 98 | @id.setter 99 | def id(self, value): 100 | self.data.set_tag(TAG_NODE_UUID, value) 101 | 102 | @Base.parent.getter 103 | def parent(self): 104 | parent_np = self.data.get_parent() 105 | if not parent_np.is_empty(): 106 | return get_base().node_manager.wrap(parent_np) 107 | else: 108 | return None 109 | 110 | @property 111 | def children(self): 112 | return [ 113 | get_base().node_manager.wrap(child) 114 | for child in self.data.get_children() 115 | ] 116 | 117 | def detach(self): 118 | self.data.detach_node() 119 | 120 | def destroy(self): 121 | self.data.remove_node() 122 | 123 | @property 124 | def tags(self): 125 | tags = self.data.get_python_tag(TAG_PYTHON_TAGS) 126 | if tags is not None: 127 | return [ 128 | tag 129 | for tag in tags 130 | if tag in get_base().node_manager.wrappers 131 | ] 132 | return [] 133 | 134 | def add_child(self, child): 135 | child.data.reparent_to(self.data) 136 | 137 | @classmethod 138 | def find_child(cls, path, parent): 139 | buffer = path.split('|') 140 | np = parent.data 141 | for elem in buffer: 142 | child_names = [child.get_name() for child in np.get_children()] 143 | index = child_names.index(elem) 144 | np = np.get_children()[index] 145 | return np 146 | 147 | def GetActor(self): 148 | """ 149 | Return the actor part of this NodePath if there is one, return None 150 | otherwise. 151 | """ 152 | return self.data.getPythonTag(TAG_ACTOR) 153 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/nongraphobject.py: -------------------------------------------------------------------------------- 1 | from game.nodes.base import Base 2 | from direct.showbase.PythonUtil import getBase as get_base 3 | 4 | 5 | class Metaobject: 6 | 7 | def __init__(self): 8 | self.id = None 9 | self.tags = {} 10 | 11 | 12 | class NonGraphObject(Base): 13 | 14 | @classmethod 15 | def create(cls, *args, **kwargs): 16 | comp = super().create(cls, *args, **kwargs) 17 | comp._metaobject = Metaobject() 18 | return comp 19 | 20 | @property 21 | def metaobject(self): 22 | return self._metaobject if hasattr(self, '_metaobject') else get_base().scene.objects[self.data] 23 | 24 | def detach(self): 25 | del get_base().scene.objects[self.data] 26 | 27 | def destroy(self): 28 | pass 29 | 30 | def add_child(self, child): 31 | raise NotImplementedError('Cannot parent non graph objects') 32 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/pandanode.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pm 2 | 3 | from game.nodes.nodepath import NodePath 4 | 5 | 6 | class PandaNode(NodePath): 7 | 8 | type_ = pm.PandaNode 9 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/particleeffect.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from direct.particles.ParticleEffect import ParticleEffect as DirectParticleEffect 4 | from direct.showbase.PythonUtil import getBase as get_base 5 | import panda3d.core as pc 6 | 7 | from game.nodes.attributes import PythonTagAttribute 8 | from game.nodes.constants import TAG_NODE_TYPE 9 | from game.nodes.nodepath import NodePath 10 | 11 | 12 | class ParticleEffect(NodePath): 13 | 14 | type_ = DirectParticleEffect 15 | config_path = PythonTagAttribute( 16 | pc.Filename, 17 | read_only=True, 18 | required=True, 19 | ) 20 | 21 | @classmethod 22 | def create(cls, *args, **kwargs): 23 | get_base().enable_particles() 24 | 25 | effect = DirectParticleEffect() 26 | effect.set_tag(TAG_NODE_TYPE, 'ParticleEffect') 27 | 28 | file_path = kwargs.get('config_path') 29 | if file_path is not None: 30 | # HAXXOR 31 | # Make relative to project somehow. We don't get this problem with 32 | # models because of panda's model search path. 33 | if not os.path.isabs(file_path): 34 | file_path = os.path.join(get_base().project.path, file_path) 35 | file_path = os.path.normpath(file_path) # .replace('\\', '/') 36 | file_path = pc.Filename.from_os_specific(file_path) 37 | 38 | effect.load_config(file_path) 39 | effect.start() 40 | 41 | comp = super().create(data=effect) 42 | if file_path is not None: 43 | comp.config_path = file_path 44 | 45 | return comp 46 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/sceneroot.py: -------------------------------------------------------------------------------- 1 | import panda3d.bullet as pb 2 | from direct.showbase.PythonUtil import getBase as get_base 3 | 4 | from game.nodes.attributes import Connection 5 | from game.nodes.nongraphobject import NonGraphObject 6 | from game.nodes.componentmetaclass import ComponentMetaClass 7 | 8 | 9 | def get_physics_world(scene): 10 | return scene.physics_world 11 | 12 | 13 | def set_physics_world(scene, world): 14 | scene.physics_world = world 15 | 16 | 17 | class SceneRoot(NonGraphObject, metaclass=ComponentMetaClass): 18 | 19 | physics_world = Connection( 20 | pb.BulletWorld, 21 | get_physics_world, 22 | set_physics_world, 23 | None, 24 | ) 25 | 26 | @classmethod 27 | def create(cls, *args, **kwargs): 28 | return cls(get_base().scene) 29 | 30 | @property 31 | def id(self): 32 | return None 33 | 34 | @id.setter 35 | def id(self, value): 36 | pass 37 | 38 | @property 39 | def parent(self): 40 | return None 41 | 42 | @parent.setter 43 | def parent(self, value): 44 | pass 45 | 46 | def add_child(self, child): 47 | self.data.register_component(child) 48 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/showbasedefaults.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | 3 | from game.nodes.camera import Camera 4 | from game.nodes.modelnode import ModelNode 5 | from game.nodes.nodepath import NodePath 6 | from game.nodes.componentmetaclass import ComponentMetaClass 7 | 8 | 9 | class Render(NodePath, metaclass=ComponentMetaClass): 10 | 11 | @classmethod 12 | def create(cls, *args, **kwargs): 13 | return super().create(cls, data=get_base().render) 14 | 15 | @property 16 | def parent(self): 17 | return get_base().node_manager.wrap(get_base().scene) 18 | 19 | @parent.setter 20 | def parent(self, value): 21 | pass 22 | 23 | 24 | class BaseCamera(ModelNode, metaclass=ComponentMetaClass): 25 | 26 | @classmethod 27 | def create(cls, *args, **kwargs): 28 | return super().create(cls, data=get_base().camera) 29 | 30 | 31 | class BaseCam(Camera, metaclass=ComponentMetaClass): 32 | 33 | @classmethod 34 | def create(cls, *args, **kwargs): 35 | return super().create(cls, data=get_base().cam) 36 | 37 | 38 | class Render2d(NodePath, metaclass=ComponentMetaClass): 39 | 40 | @classmethod 41 | def create(cls, *args, **kwargs): 42 | return super().create(cls, data=get_base().render2d) 43 | 44 | @property 45 | def parent(self): 46 | return get_base().node_manager.wrap(get_base().scene) 47 | 48 | @parent.setter 49 | def parent(self, value): 50 | pass 51 | 52 | 53 | class Aspect2d(NodePath, metaclass=ComponentMetaClass): 54 | 55 | @classmethod 56 | def create(cls, *args, **kwargs): 57 | return super().create(cls, data=get_base().aspect2d) 58 | 59 | 60 | class Pixel2d(NodePath, metaclass=ComponentMetaClass): 61 | 62 | @classmethod 63 | def create(cls, *args, **kwargs): 64 | return super().create(cls, data=get_base().pixel2d) 65 | 66 | 67 | class Camera2d(NodePath, metaclass=ComponentMetaClass): 68 | 69 | @classmethod 70 | def create(cls, *args, **kwargs): 71 | return super().create(cls, data=get_base().camera2d) 72 | 73 | 74 | class Cam2d(NodePath, metaclass=ComponentMetaClass): 75 | 76 | @classmethod 77 | def create(cls, *args, **kwargs): 78 | return super().create(cls, data=get_base().cam2d) 79 | -------------------------------------------------------------------------------- /src/pandaEditor/game/nodes/texture.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | from direct.showbase.PythonUtil import getBase as get_base 3 | 4 | from game.nodes.attributes import Attribute 5 | from game.nodes.nongraphobject import NonGraphObject 6 | 7 | 8 | class Texture(NonGraphObject): 9 | 10 | type_ = pc.Texture 11 | fullpath = Attribute( 12 | pc.Filename, 13 | pc.Texture.get_fullpath, 14 | required=True, 15 | ) 16 | 17 | @classmethod 18 | def create(cls, *args, **kwargs): 19 | fullpath = kwargs.pop('fullpath', None) 20 | panda_fullpath = pc.Filename.from_os_specific(fullpath) 21 | texture = get_base().loader.load_texture(panda_fullpath) 22 | return super().create(data=texture) 23 | -------------------------------------------------------------------------------- /src/pandaEditor/game/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/game/plugins/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/game/plugins/base.py: -------------------------------------------------------------------------------- 1 | from game.plugins.ibase import IBase 2 | 3 | 4 | class Base(IBase): 5 | pass 6 | -------------------------------------------------------------------------------- /src/pandaEditor/game/plugins/ibase.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | from yapsy.IPlugin import IPlugin 3 | 4 | 5 | class IBase(IPlugin): 6 | 7 | def on_init(self): 8 | pass 9 | 10 | def register_node_wrapper(self, type_str, cls): 11 | get_base().game.node_manager.wrappers[type_str] = cls 12 | -------------------------------------------------------------------------------- /src/pandaEditor/game/plugins/manager.py: -------------------------------------------------------------------------------- 1 | from yapsy.PluginManager import PluginManager 2 | 3 | 4 | class Manager(PluginManager): 5 | 6 | def on_init(self): 7 | for plugin in self.getAllPlugins(): 8 | plugin.plugin_object.on_init() 9 | -------------------------------------------------------------------------------- /src/pandaEditor/game/scene.py: -------------------------------------------------------------------------------- 1 | from p3d.object import Object 2 | 3 | 4 | class Scene(Object): 5 | 6 | cType = 'SceneRoot' 7 | 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | 11 | self.objects = {} 12 | self.physics_world = None 13 | self.physics_task = None 14 | 15 | def register_component(self, comp): 16 | self.objects[comp.data] = comp._metaobject 17 | del comp._metaobject 18 | 19 | def deregister_component(self, comp): 20 | del self.objects[comp.data] 21 | -------------------------------------------------------------------------------- /src/pandaEditor/game/sceneparser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import xml.etree.ElementTree as et 3 | from direct.showbase.PythonUtil import getBase as get_base 4 | 5 | from p3d.commonUtils import unserialise 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class SceneParser: 12 | 13 | """A class to load map files into Panda3D.""" 14 | 15 | def load(self, file_path, pcomp=None): 16 | """Load the scene from an xml file.""" 17 | 18 | # Include connections that exist already in the scene. 19 | self.nodes = {} 20 | for obj in get_base().scene.objects: 21 | comp = get_base().node_manager.wrap(obj) 22 | self.nodes[comp.id] = comp 23 | 24 | self.connections = {} 25 | 26 | tree = et.parse(file_path) 27 | relem = tree.getroot() 28 | rcomp = self.load_component(relem, pcomp) 29 | self.load_connections() 30 | 31 | return rcomp 32 | 33 | def get_attributes(self, pelem, comp_cls): 34 | attrs = {} 35 | cls_attrs = comp_cls.properties 36 | for elem in pelem.findall('Item'): 37 | name = elem.get('name') 38 | value_str = elem.get('value') 39 | attrs[name] = unserialise(value_str, cls_attrs[name].type) 40 | return attrs 41 | 42 | def load_component(self, elem, pcomp): 43 | comp_type = elem.get('type') 44 | logger.info(f'Load component: {comp_type}') 45 | comp_cls = get_base().node_manager.get_component_by_name(comp_type) 46 | assert comp_cls is not None, f'Unknown component class: {comp_type}' 47 | 48 | # Collect attribute keys and values. 49 | attrs = self.get_attributes(elem, comp_cls) 50 | kwargs = { 51 | attr_name: attrs.pop(attr_name) 52 | for attr_name, attr in comp_cls.create_attributes.items() 53 | } 54 | 55 | # For sub-models edits we need to pull out the path for the 56 | # constructor. 57 | if 'path' in elem.attrib: 58 | kwargs['path'] = elem.attrib['path'] 59 | kwargs['parent'] = pcomp 60 | 61 | # Create the node and load it`s properties. 62 | comp = comp_cls.create(**kwargs) 63 | if pcomp is not None: 64 | comp.parent = pcomp 65 | comp.id = elem.get('id') 66 | self.nodes[comp.id] = comp 67 | 68 | for name, value in attrs.items(): 69 | setattr(comp, name, value) 70 | 71 | # Store connections so we can set them up once the rest of 72 | # the scene has been loaded. 73 | cnctnsElem = elem.find('Connections') 74 | if cnctnsElem is not None: 75 | cnctnDict = {} 76 | for cnctnElem in cnctnsElem: 77 | cType = cnctnElem.get('type') 78 | uuid = cnctnElem.get('value') 79 | cnctnDict.setdefault(cType, []) 80 | cnctnDict[cType].append(uuid) 81 | self.connections[comp] = cnctnDict 82 | 83 | # Recurse through hierarchy. 84 | for cElem in elem.findall('Component'): 85 | self.load_component(cElem, comp) 86 | 87 | return comp 88 | 89 | def load_connections(self): 90 | for comp, connections in self.connections.items(): 91 | for name, comp_ids in connections.items(): 92 | for comp_id in comp_ids: 93 | setattr(comp, name, self.nodes[comp_id]) 94 | -------------------------------------------------------------------------------- /src/pandaEditor/game/showbase.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.ShowBase import ShowBase as DirectShowBase 2 | 3 | from game.nodes.manager import Manager as NodeManager 4 | from game.plugins.manager import Manager as PluginManager 5 | from game.sceneparser import SceneParser 6 | from game.scene import Scene 7 | 8 | 9 | class ShowBase(DirectShowBase): 10 | 11 | node_manager_cls = NodeManager 12 | plug_manager_cls = PluginManager 13 | scene_parser_cls = SceneParser 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | self.node_manager = self.node_manager_cls() 19 | self.plugin_manager = self.plug_manager_cls() 20 | self.scene_parser = self.scene_parser_cls() 21 | 22 | def load_plugins(self): 23 | self.plugin_manager.setPluginPlaces([ 24 | r'C:\Users\Jamie Davies\Documents\git\panda3d-editor\src\plugins', 25 | r'C:\Users\Jamie Davies\Documents\git\reactor\plugins', 26 | ]) 27 | self.plugin_manager.collectPlugins() 28 | self.plugin_manager.on_init() 29 | 30 | def load_scene(self, file_path): 31 | self.scene = Scene(self, camera=None) 32 | self.scene.load(file_path) 33 | -------------------------------------------------------------------------------- /src/pandaEditor/game/utils.py: -------------------------------------------------------------------------------- 1 | def get_unique_name(name, elems): 2 | """ 3 | Return a unique version of the name indicated by incrementing a numeral 4 | at the end. Stop when the name no longer appears in the indicated list of 5 | elements. 6 | 7 | """ 8 | digits = [] 9 | for c in reversed(name): 10 | if c.isdigit(): 11 | digits.append(c) 12 | else: 13 | break 14 | 15 | stem = name[0:len(name) - len(digits)] 16 | val = ''.join(digits)[::-1] or 0 17 | i = int(val) 18 | 19 | while True: 20 | i += 1 21 | new_name = ''.join([stem, str(i)]) 22 | if new_name not in elems: 23 | break 24 | 25 | return new_name 26 | 27 | 28 | def get_lower_camel_case(name): 29 | return name[0].lower() + name[1:] if name else name 30 | -------------------------------------------------------------------------------- /src/pandaEditor/gizmos/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import * 2 | 3 | from .manager import Manager 4 | from .axis import Axis 5 | from .base import Base 6 | from .translation import Translation 7 | from .rotation import Rotation 8 | from .scale import Scale -------------------------------------------------------------------------------- /src/pandaEditor/gizmos/axis.py: -------------------------------------------------------------------------------- 1 | from panda3d.core import Point3, NodePath, GeomEnums 2 | from panda3d.core import CollisionNode, CollisionCapsule 3 | 4 | from .constants import * 5 | 6 | 7 | class Axis(NodePath): 8 | 9 | def __init__(self, name, vector, colour, planar=False, default=False): 10 | NodePath.__init__(self, name) 11 | 12 | self.name = name 13 | self.vector = vector 14 | self.colour = colour 15 | self.planar = planar 16 | self.default = default 17 | 18 | self.highlited = False 19 | self.selected = False 20 | self.size = 1 21 | 22 | self.geoms = [] 23 | self.collNodes = [] 24 | self.collNodePaths = [] 25 | 26 | def AddGeometry(self, geom, pos=Point3(0, 0, 0), colour=None, 27 | highlight=True, sizeStyle=TRANSLATE): 28 | """ 29 | Add geometry to represent the axis and move it into position. If the 30 | geometry is a line make sure to call setLightOff or else it won't 31 | look right. 32 | """ 33 | geom.setPos(pos) 34 | geom.setPythonTag('highlight', highlight) 35 | geom.setPythonTag('sizeStyle', sizeStyle) 36 | geom.reparentTo(self) 37 | 38 | # If colour is not specified then use the axis colour 39 | if colour is None: 40 | colour = self.colour 41 | geom.setColorScale(colour) 42 | 43 | # Set light off if the geometry is a line 44 | if geom.node().getGeom(0).getPrimitiveType() == GeomEnums.PTLines: 45 | geom.setLightOff() 46 | 47 | self.geoms.append(geom) 48 | 49 | def AddCollisionSolid(self, collSolid, pos=Point3(0, 0, 0), 50 | sizeStyle=TRANSLATE): 51 | """Add a collision solid to the axis and move it into position.""" 52 | # Create the collision node and add the solid 53 | collNode = CollisionNode(self.name) 54 | collNode.addSolid(collSolid) 55 | self.collNodes.append(collNode) 56 | 57 | # Create a node path and move it into position 58 | collNodePath = self.attachNewNode(collNode) 59 | collNodePath.setPos(pos) 60 | collNodePath.setPythonTag('sizeStyle', sizeStyle) 61 | self.collNodePaths.append(collNodePath) 62 | 63 | def SetSize(self, size): 64 | """ 65 | Change the size of the gizmo. This isn't just the same as scaling all 66 | the geometry and collision - sometimes this just means pushing the 67 | geometry along the axis instead. 68 | """ 69 | oldSize = self.size 70 | self.size = size 71 | 72 | nodePaths = self.geoms + self.collNodePaths 73 | for nodePath in nodePaths: 74 | 75 | # Get the size style 76 | sizeStyle = nodePath.getPythonTag('sizeStyle') 77 | if sizeStyle & NONE: 78 | continue 79 | 80 | # Set scale 81 | if sizeStyle & SCALE: 82 | nodePath.setScale(self.size) 83 | 84 | # Set position 85 | if sizeStyle & TRANSLATE: 86 | 87 | # Get the position of the node path relative to the axis end 88 | # point (vector), then move the geometry and reapply this 89 | # offset 90 | diff = (self.vector * oldSize) - nodePath.getPos() 91 | nodePath.setPos(Point3((self.vector * self.size) - diff)) 92 | 93 | # Should only be used for collision tubes 94 | if sizeStyle & TRANSLATE_POINT_B: 95 | collSolid = nodePath.node().modifySolid(0) 96 | if type(collSolid) == CollisionCapsule: 97 | 98 | # Get the position of the capsule's B point relative to 99 | # the axis end point (vector), then move the point and 100 | # reapply this offset 101 | diff = (self.vector * oldSize) - collSolid.getPointB() 102 | collSolid.setPointB(Point3((self.vector * self.size) - diff)) 103 | 104 | def Select(self): 105 | """ 106 | Changed the colour of the axis to the highlight colour and flag as 107 | being selected. 108 | """ 109 | self.selected = True 110 | self.Highlight() 111 | 112 | def Deselect(self): 113 | """ 114 | Reset the colour of the axis to the original colour and flag as being 115 | unselected. 116 | """ 117 | self.selected = False 118 | self.Unhighlight() 119 | 120 | def Highlight(self): 121 | """Highlight the axis by changing it's colour.""" 122 | for geom in self.geoms: 123 | if geom.getPythonTag('highlight'): 124 | geom.setColorScale(YELLOW) 125 | 126 | def Unhighlight(self): 127 | """Remove the highlight by resetting to the axis colour.""" 128 | for geom in self.geoms: 129 | if geom.getPythonTag('highlight'): 130 | geom.setColorScale(self.colour) -------------------------------------------------------------------------------- /src/pandaEditor/gizmos/constants.py: -------------------------------------------------------------------------------- 1 | RED = (1, 0, 0, 0) 2 | GREEN = (0, 1, 0, 0) 3 | BLUE = (0, 0, 1, 0) 4 | YELLOW = (1, 1, 0, 0) 5 | TEAL = (0, 1, 1, 0) 6 | GREY = (0.5, 0.5, 0.5, 0.5) 7 | 8 | TRANSLATE = 1 9 | TRANSLATE_POINT_A = 2 10 | TRANSLATE_POINT_B = 4 11 | SCALE = 8 12 | NONE = 16 13 | CAMERA_VECTOR = 32 -------------------------------------------------------------------------------- /src/pandaEditor/gizmos/manager.py: -------------------------------------------------------------------------------- 1 | from panda3d.core import DirectionalLight 2 | 3 | from p3d.mousePicker import MousePicker 4 | from p3d.object import Object 5 | 6 | 7 | class Manager(Object): 8 | 9 | def __init__(self, *args, **kwargs): 10 | Object.__init__(self, *args, **kwargs) 11 | 12 | self._gizmos = {} 13 | self._activeGizmo = None 14 | 15 | # Create gizmo manager mouse picker 16 | self.picker = MousePicker('mouse', *args, **kwargs) 17 | self.picker.Start() 18 | 19 | # Create a directional light and attach it to the camera so the gizmos 20 | # don't look flat 21 | dl = DirectionalLight('gizmoManagerDirLight') 22 | self.dlNp = self.camera.attachNewNode(dl) 23 | self.rootNp.setLight(self.dlNp) 24 | 25 | def AddGizmo(self, gizmo): 26 | """Add a gizmo to be managed by the gizmo manager.""" 27 | gizmo.rootNp = self.rootNp 28 | self._gizmos[gizmo.getName()] = gizmo 29 | 30 | def GetGizmo(self, name): 31 | """ 32 | Find and return a gizmo by name, return None if no gizmo with the 33 | specified name exists. 34 | """ 35 | if name in self._gizmos: 36 | return self._gizmos[name] 37 | 38 | return None 39 | 40 | def GetActiveGizmo(self): 41 | """Return the active gizmo.""" 42 | return self._activeGizmo 43 | 44 | def SetActiveGizmo(self, name): 45 | """ 46 | Stops the currently active gizmo then finds the specified gizmo by 47 | name and starts it. 48 | """ 49 | # Stop the active gizmo 50 | if self._activeGizmo is not None: 51 | self._activeGizmo.Stop() 52 | 53 | # Get the gizmo by name and start it if it is a valid gizmo 54 | self._activeGizmo = self.GetGizmo(name) 55 | if self._activeGizmo is not None: 56 | self._activeGizmo.Start() 57 | 58 | def RefreshActiveGizmo(self): 59 | """Refresh the active gizmo if there is one.""" 60 | if self._activeGizmo is not None: 61 | self._activeGizmo.Refresh() 62 | 63 | def GetGizmoLocal(self, name): 64 | """Return the gizmos local mode.""" 65 | gizmo = self.GetGizmo(name) 66 | if gizmo is not None: 67 | return gizmo.local 68 | 69 | def SetGizmoLocal(self, name, mode): 70 | """Set all gizmo local modes, then refresh the active one.""" 71 | gizmo = self.GetGizmo(name) 72 | if gizmo is not None: 73 | gizmo.local = mode 74 | self.RefreshActiveGizmo() 75 | 76 | def SetLocal(self, val): 77 | for gizmo in self._gizmos.values(): 78 | gizmo.local = val 79 | self.RefreshActiveGizmo() 80 | 81 | def ToggleLocal(self): 82 | """Toggle all gizmos local mode on or off.""" 83 | for gizmo in self._gizmos.values(): 84 | gizmo.local = not gizmo.local 85 | self.RefreshActiveGizmo() 86 | 87 | def SetSize(self, factor): 88 | """Resize the gizmo by a factor.""" 89 | for gizmo in self._gizmos.values(): 90 | gizmo.SetSize(factor) 91 | 92 | def AttachNodePaths(self, nps): 93 | """Attach node paths to be transformed by the gizmos.""" 94 | for gizmo in self._gizmos.values(): 95 | gizmo.AttachNodePaths(nps) 96 | 97 | def IsDragging(self): 98 | """ 99 | Return True if the active gizmo is in the middle of a dragging 100 | operation, False otherwise. 101 | """ 102 | return self._activeGizmo is not None and self._activeGizmo.dragging -------------------------------------------------------------------------------- /src/pandaEditor/gizmos/scale.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from panda3d.core import Mat4, Vec3, Point3, CollisionSphere, NodePath 4 | 5 | from p3d import commonUtils 6 | from p3d.geometry import Line, box 7 | from .axis import Axis 8 | from .base import Base 9 | from .constants import * 10 | 11 | 12 | class Scale(Base): 13 | 14 | def __init__(self, *args, **kwargs): 15 | Base.__init__(self, *args, **kwargs) 16 | 17 | self.complementary = False 18 | 19 | # Create x, y, z and center axes 20 | self.axes.append(self.CreateBox(Vec3(1, 0, 0), RED)) 21 | self.axes.append(self.CreateBox(Vec3(0, 1, 0), GREEN)) 22 | self.axes.append(self.CreateBox(Vec3(0, 0, 1), BLUE)) 23 | self.axes.append(self.CreateCenter(Vec3(1, 1, 1), TEAL)) 24 | 25 | def CreateBox(self, vector, colour): 26 | 27 | # Create the geometry and collision 28 | line = NodePath(Line((0, 0, 0), vector)) 29 | box_ = NodePath(box(0.1, 0.1, 0.1, vector * 0.05)) 30 | collSphere = CollisionSphere(Point3(vector * -0.05), 0.1) 31 | 32 | # Create the axis, add the geometry and collision 33 | axis = Axis(self.name, vector, colour) 34 | axis.AddGeometry(line, colour=GREY, highlight=False, sizeStyle=SCALE) 35 | axis.AddGeometry(box_, vector, colour) 36 | axis.AddCollisionSolid(collSphere, vector) 37 | axis.reparentTo(self) 38 | 39 | return axis 40 | 41 | def CreateCenter(self, vector, colour): 42 | 43 | # Create the axis, add the geometry and collision 44 | axis = Axis(self.name, vector, colour, default=True) 45 | axis.AddGeometry(NodePath(box(0.1, 0.1, 0.1)), sizeStyle=NONE) 46 | axis.AddCollisionSolid(CollisionSphere(0, 0.1), sizeStyle=NONE) 47 | axis.reparentTo(self) 48 | 49 | return axis 50 | 51 | def Transform(self): 52 | 53 | # Get the distance the mouse has moved during the drag operation - 54 | # compensate for how big the gizmo is on the screen 55 | axis = self.GetSelectedAxis() 56 | axisPoint = self.GetAxisPoint(axis) 57 | distance = (axisPoint - self.startAxisPoint).length() / self.getScale()[0] 58 | 59 | # Using length() will give us a positive number, which doesn't work if 60 | # we're trying to scale down the object. Get the sign for the distance 61 | # from the dot of the axis and the mouse direction 62 | mousePoint = self.getRelativePoint(self.rootNp, axisPoint) - self.getRelativePoint(self.rootNp, self.startAxisPoint) 63 | direction = axis.vector.dot(mousePoint) 64 | sign = math.copysign(1, direction) 65 | distance = distance * sign 66 | 67 | # Transform the gizmo 68 | if axis.vector == Vec3(1, 1, 1): 69 | for otherAxis in self.axes: 70 | otherAxis.SetSize(distance + self.size) 71 | else: 72 | axis.SetSize(distance + self.size) 73 | 74 | # Use the "complementary" vector if in complementary mode 75 | vector = axis.vector 76 | if self.complementary: 77 | vector = Vec3(1, 1, 1) - axis.vector 78 | 79 | # Create a scale matrix from the resulting vector 80 | scaleVec = vector * (distance + 1) + Vec3(1, 1, 1) - vector 81 | newScaleMat = Mat4().scaleMat(scaleVec) 82 | 83 | # Transform attached node paths 84 | for i, np in enumerate(self.attachedNps): 85 | 86 | # Perform transforms in local or world space 87 | if self.local: 88 | np.setMat(newScaleMat * self.initNpXforms[i].getMat()) 89 | else: 90 | transMat, rotMat, scaleMat = commonUtils.GetTrsMatrices(self.initNpXforms[i]) 91 | np.setMat(scaleMat * rotMat * newScaleMat * transMat) 92 | 93 | def OnNodeMouse1Down(self, planar, collEntry): 94 | 95 | # Cheating a bit here. We just need the planar flag taken from the 96 | # user ctrl-clicking the gizmo, none of the maths that come with it. 97 | # We'll use the complementary during the transform operation. 98 | self.complementary = planar 99 | planar = False 100 | 101 | Base.OnNodeMouse1Down(self, planar, collEntry) -------------------------------------------------------------------------------- /src/pandaEditor/nodes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/nodes/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/nodes/actor.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | 3 | from game.nodes.constants import TAG_MODEL_PATH 4 | from pandaEditor.nodes.constants import TAG_PICKABLE 5 | 6 | 7 | class Actor: 8 | 9 | @classmethod 10 | def create(cls, *args, **kwargs): 11 | comp = super().create(*args, **kwargs) 12 | comp.data.set_python_tag(TAG_PICKABLE, True) 13 | return comp 14 | 15 | def duplicate(self): 16 | dupe = super().duplicate() 17 | dupe.set_python_tag(TAG_PICKABLE, True) 18 | return dupe 19 | 20 | # TODO: Make attribute. 21 | # def get_full_path(self, node): 22 | # model_path = node.get_python_tag(TAG_MODEL_PATH) 23 | # rel_path = get_base().project.get_rel_model_path(model_path) 24 | # return rel_path 25 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/attributes.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | 3 | 4 | class Connection: 5 | 6 | def __set__(self, instance, value): 7 | super().__set__(instance, value) 8 | 9 | # TODO: This possibly should register when set then deregister when 10 | # set to None. 11 | if value is not None: 12 | get_base().scene.register_connection(instance, value, self.name) 13 | 14 | def clear(self, value): 15 | 16 | # Possibly on the wrong class - move to Connections class. 17 | super().clear(value) 18 | get_base().scene.deregister_connection(value) 19 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class Base: 5 | 6 | default_values = {} 7 | 8 | @classmethod 9 | def get_default_values(cls): 10 | return cls.default_values.copy() 11 | 12 | @classmethod 13 | def get_foo(cls): 14 | return list(cls.get_default_values().keys()) 15 | 16 | @property 17 | @abc.abstractmethod 18 | def label(self): 19 | """""" 20 | 21 | def validate_drag_drop(self, dragComp, dropComp): 22 | return False 23 | 24 | def get_attrib(self): 25 | """ 26 | Return a dictionary with bare minimum data for a component - its type 27 | and id. 28 | """ 29 | 30 | # TODO: Is this required? Seems like we shouldn't care where the args 31 | # are stored, and this should be in sceneparser anyway. 32 | attrib = { 33 | 'id': self.id, 34 | 'type': self.type, 35 | } 36 | if attrib['id'] is None: 37 | del attrib['id'] 38 | return attrib 39 | 40 | @property 41 | def modified(self): 42 | return False 43 | 44 | @modified.setter 45 | def modified(self, value): 46 | pass 47 | 48 | @property 49 | def savable(self): 50 | return True 51 | 52 | def is_of_type(self, type_): 53 | return type_ in self.data.__class__.__mro__ 54 | 55 | def get_possible_connections(self, comps): 56 | """ 57 | Return a dict of connections that can be made with the given 58 | components. 59 | 60 | """ 61 | conns = {} 62 | for conn_name, conn in self.__class__.connections.items(): 63 | if any([comp.is_of_type(conn.type) for comp in comps]): 64 | conns[conn_name] = conn 65 | return conns 66 | 67 | def set_default_values(self): 68 | pass 69 | 70 | @property 71 | @abc.abstractmethod 72 | def default_parent(self): 73 | """""" 74 | 75 | @property 76 | def sibling_index(self): 77 | """ 78 | Return the position of of this wrapper's component amongst its sibling 79 | components. 80 | 81 | """ 82 | parent = self.parent 83 | return parent.children.index(self) if parent is not None else None 84 | 85 | def on_select(self): 86 | pass 87 | 88 | def on_deselect(self): 89 | pass 90 | 91 | @abc.abstractmethod 92 | def duplicate(self): 93 | """""" 94 | 95 | def fix_up_duplicate_hierarchy(self, orig, dupe): 96 | orig_children = orig.children 97 | dupe_children = dupe.children 98 | for i in range(len(orig_children)): 99 | self.fix_up_duplicate_hierarchy( 100 | orig_children[i], 101 | dupe_children[i] 102 | ) 103 | 104 | @property 105 | def attributes(self): 106 | return { 107 | name: getattr(self, name) 108 | for name, prop in self.__class__.attributes.items() 109 | } 110 | 111 | @property 112 | def connections(self): 113 | conns = {} 114 | for name, prop in self.__class__.connections.items(): 115 | targets = getattr(self, name) 116 | if targets is None: 117 | continue 118 | conns[name] = targets 119 | return conns 120 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/bullet.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | from panda3d import bullet 3 | 4 | import panda3d.bullet as pb 5 | import panda3d.core as pc 6 | 7 | from game.nodes.attributes import Attribute 8 | 9 | 10 | TAG_BULLET_DEBUG_WIREFRAME = 'P3D_BulletDebugWireframe' 11 | 12 | 13 | class BulletBoxShape: 14 | 15 | default_values = { 16 | 'halfExtents': pc.Vec3(0.5, 0.5, 0.5), 17 | } 18 | 19 | 20 | class BulletCapsuleShape: 21 | 22 | default_values = { 23 | 'radius': 0.5, 24 | 'height': 1, 25 | 'up': pb.ZUp, 26 | } 27 | 28 | @classmethod 29 | def create(cls, *args, **kwargs): 30 | comp = super().create(cls, *args, **kwargs) 31 | comp.up = kwargs['up'] 32 | return comp 33 | 34 | 35 | def get_wireframe(obj): 36 | return obj.get_python_tag(TAG_BULLET_DEBUG_WIREFRAME) 37 | 38 | 39 | def set_wireframe(obj, value): 40 | obj.node().show_wireframe(value) 41 | obj.set_python_tag(TAG_BULLET_DEBUG_WIREFRAME, value) 42 | 43 | 44 | class BulletDebugNode: 45 | 46 | show_wireframe = Attribute(bool, get_wireframe, set_wireframe) 47 | 48 | def set_default_values(self): 49 | super().set_default_values() 50 | 51 | # Show debug wireframe by default. 52 | self.show_wireframe = True 53 | self.data.show() # This appears to be necessary?? 54 | 55 | # Connect this node to the physics world if there is one. 56 | world = get_base().scene.physics_world 57 | if world is not None: 58 | world_comp = get_base().node_manager.wrap(world) 59 | world_comp.debug_node = self 60 | 61 | 62 | class BulletPlaneShape: 63 | 64 | default_values = { 65 | 'normal': pc.Vec3(0, 0, 1), 66 | 'point': pc.Vec3(0, 0, 0), 67 | } 68 | 69 | @classmethod 70 | def create(cls, *args, **kwargs): 71 | comp = super().create(cls, *args, **kwargs) 72 | comp.normal = kwargs['normal'] 73 | comp.point = kwargs['point'] 74 | return comp 75 | 76 | 77 | class BulletRigidBodyNode: 78 | 79 | debug_enabled = Attribute( 80 | bool, 81 | pb.BulletRigidBodyNode.is_debug_enabled, 82 | pb.BulletRigidBodyNode.set_debug_enabled, 83 | node_data=True, 84 | serialise=False, 85 | ) 86 | num_shapes = Attribute( 87 | int, 88 | pb.BulletRigidBodyNode.get_num_shapes, 89 | node_data=True, 90 | serialise=False 91 | ) 92 | 93 | def set_default_values(self): 94 | super().set_default_values() 95 | 96 | # Connect this node to the physics world if there is one. 97 | world = get_base().scene.physics_world 98 | if world is not None: 99 | world_comp = get_base().node_manager.wrap(world) 100 | world_comp.rigid_bodies.append(self) 101 | 102 | 103 | class BulletSphereShape: 104 | 105 | default_values = { 106 | 'radius': 0.5, 107 | } 108 | 109 | 110 | class BulletWorld: 111 | 112 | num_rigid_bodies = Attribute( 113 | int, 114 | bullet.BulletWorld.get_num_rigid_bodies, 115 | serialise=False, 116 | ) 117 | 118 | def set_default_values(self): 119 | super().set_default_values() 120 | 121 | # Use realistic gravity value as a default. 122 | self.gravity = pc.Vec3(0, 0, -9.81) 123 | 124 | # Set this world as the physics world if there isn't already one. 125 | world = get_base().scene.physics_world 126 | if world is None: 127 | scene = get_base().node_manager.wrap(get_base().scene) 128 | scene.physics_world = self 129 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/collision.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import Attribute 4 | 5 | 6 | class CollisionBox: 7 | 8 | default_values = { 9 | 'min': pc.Point3(-0.5, -0.5, -0.5), 10 | 'max': pc.Point3(0.5, 0.5, 0.5), 11 | } 12 | 13 | 14 | class CollisionCapsule: 15 | 16 | default_values = { 17 | 'a': pc.Point3(0), 18 | 'db': pc.Point3(0, 0, 1), 19 | 'radius': 0.5, 20 | } 21 | 22 | 23 | class CollisionNode: 24 | 25 | num_solids = Attribute( 26 | int, 27 | pc.CollisionNode.get_num_solids, 28 | serialise=False, 29 | node_data=True, 30 | ) 31 | 32 | @classmethod 33 | def create(cls, *args, **kwargs): 34 | comp = super().create(*args, **kwargs) 35 | comp.data.show() # TODO: Expose as editor property 36 | return comp 37 | 38 | 39 | class CollisionRay: 40 | 41 | default_values = { 42 | 'origin': pc.Point3(0), 43 | 'direction': pc.Vec3(0, 0, 1), 44 | } 45 | 46 | 47 | class CollisionSphere: 48 | 49 | default_values = { 50 | 'center': pc.Point3(0), 51 | 'radius': 0.5, 52 | } 53 | 54 | 55 | class CollisionInvSphere: 56 | 57 | default_values = { 58 | 'center': pc.Point3(0), 59 | 'radius': 0.5, 60 | } 61 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/constants.py: -------------------------------------------------------------------------------- 1 | TAG_BBOX = 'P3D_Bbox' 2 | TAG_IGNORE = 'P3D_IgnoreNode' 3 | TAG_PICKABLE = 'P3D_PickableNode' 4 | TAG_MODIFIED = 'P3D_Modified' 5 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/lensnode.py: -------------------------------------------------------------------------------- 1 | from game.nodes.attributes import Attribute 2 | from pandaEditor.nodes.constants import TAG_IGNORE 3 | 4 | 5 | TAG_FRUSTUM = 'P3D_Fustum' 6 | 7 | 8 | def get_frustrum(data): 9 | """ 10 | Return True if the lens node's frustum is visible, False otherwise. 11 | 12 | """ 13 | return any( 14 | child.get_python_tag(TAG_FRUSTUM) 15 | for child in data.get_children() 16 | ) 17 | 18 | def set_frustrum(data, value): 19 | """ 20 | Set the camera's frustum to be visible. Ensure it is tagged for removal 21 | and also so it doesn't appear in any of the scene graph panels. 22 | 23 | """ 24 | if not value: 25 | data.node().hide_frustum() 26 | else: 27 | before = set(data.get_children()) 28 | data.node().show_frustum() 29 | after = set(data.get_children()) 30 | frustum = next(iter(after - before)) 31 | frustum.set_python_tag(TAG_FRUSTUM, True) 32 | frustum.set_python_tag(TAG_IGNORE, True) 33 | 34 | 35 | class LensNode: 36 | 37 | show_frustrum = Attribute( 38 | bool, 39 | get_frustrum, 40 | set_frustrum, 41 | serialise=False 42 | ) 43 | 44 | def on_select(self): 45 | """ 46 | Selection handler. Make sure to disable the frustum if it was shown 47 | before running the select handler as the frustum will change the size 48 | of the bounding box. 49 | 50 | """ 51 | visible = self.show_frustrum 52 | self.show_frustrum = False 53 | super().on_select() 54 | if visible: 55 | self.show_frustrum = True 56 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/lights.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | 3 | 4 | class Light: 5 | 6 | def set_default_values(self): 7 | super().set_default_values() 8 | 9 | # Automatically set render to use this light. 10 | render = get_base().node_manager.wrap(get_base().render) 11 | render.lights.append(self) 12 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import panda3d.core as pc 4 | 5 | from game.nodes.manager import Manager as GameManager 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Manager(GameManager): 12 | 13 | def get_default_wrapper(self, obj): 14 | if isinstance(obj, pc.NodePath): 15 | return self.wrappers['NodePath'] 16 | else: 17 | return self.wrappers['NonGraphObject'] 18 | 19 | def get_common_wrapper(self, comps): 20 | 21 | # Get method resolution orders for each wrapper for all the indicated 22 | # components. 23 | mros = [] 24 | for comp in comps: 25 | comp_cls = self.get_wrapper(comp.data) 26 | if comp_cls is not None: 27 | mros.append(comp_cls.__mro__) 28 | if not mros: 29 | return self.get_default_wrapper(comps[0].data) 30 | 31 | # Intersect the mros to get the common classes. 32 | first_mro = mros[0] 33 | common_bases = sorted( 34 | set(first_mro).intersection(*mros), 35 | key=first_mro.index 36 | ) 37 | 38 | dicts = {} 39 | dicts.update({'change_mro': False}) 40 | 41 | try: 42 | common_wrapper = type( 43 | common_bases[0].__name__, 44 | tuple(common_bases), 45 | dicts#{'change_mro': False} 46 | ) 47 | except TypeError as e: 48 | logger.error(f'Failed to create wrapper: {tuple(common_bases)}') 49 | raise 50 | common_base_names = [b.__name__ for b in common_bases] 51 | logger.info(f'Using bases for common wrapper: {common_base_names}') 52 | return common_wrapper 53 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/modelroot.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import ProjectAssetAttribute 4 | 5 | 6 | class ModelRoot: 7 | 8 | serialise_descendants = False 9 | fullpath = ProjectAssetAttribute( 10 | pc.Filename, 11 | pc.ModelRoot.get_fullpath, 12 | required=True, 13 | node_data=True, 14 | ) 15 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/nongraphobject.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from direct.showbase.PythonUtil import getBase as get_base 4 | 5 | from game.utils import get_lower_camel_case 6 | 7 | 8 | class NonGraphObject: 9 | 10 | @property 11 | def label(self): 12 | return get_lower_camel_case(self.data.__class__.__name__) 13 | 14 | @property 15 | def default_parent(self): 16 | return get_base().node_manager.wrap(get_base().scene) 17 | 18 | def duplicate(self): 19 | dupe = get_base().node_manager.wrap(copy.copy(self.data)) 20 | dupe._metaobject = copy.copy(self.metaobject) 21 | self.fix_up_duplicate_hierarchy(self, dupe) 22 | get_base().scene.register_component(dupe) 23 | return dupe 24 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/particleeffect.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import ProjectAssetPythonTagAttribute 4 | 5 | 6 | class ParticleEffect: 7 | 8 | serialise_descendants = False 9 | config_path = ProjectAssetPythonTagAttribute( 10 | pc.Filename, 11 | read_only=True, 12 | required=True, 13 | ) 14 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/sceneroot.py: -------------------------------------------------------------------------------- 1 | from direct.showbase.PythonUtil import getBase as get_base 2 | 3 | 4 | class SceneRoot: 5 | 6 | serialise_descendants = True 7 | 8 | # TODO: Move to game class? 9 | @property 10 | def children(self): 11 | return [ 12 | get_base().node_manager.wrap(data) 13 | for data in [ 14 | get_base().render2d, 15 | get_base().render, 16 | ] + list(get_base().scene.objects.keys()) 17 | ] 18 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/showbasedefaults.py: -------------------------------------------------------------------------------- 1 | from pandaEditor.nodes.constants import TAG_IGNORE 2 | 3 | 4 | class Aspect2d: 5 | 6 | @classmethod 7 | def create(cls, *args, **kwargs): 8 | comp = super().create(*args, **kwargs) 9 | 10 | # Tag all NodePaths under this node with the ignore tag. They are used 11 | # to help calculate the aspect ratio and don't need to be saved out or 12 | # edited. As long as this NodePath wrapper is created before parenting 13 | # any other NodePaths the user may have created we shouldn't get into 14 | # much trouble. 15 | for np in comp.data.get_children(): 16 | np.set_python_tag(TAG_IGNORE, True) 17 | return comp 18 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/nodes/tests/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/test_bullet.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import panda3d.bullet as pb 4 | import panda3d.core as pc 5 | from direct.showbase.PythonUtil import getBase as get_base 6 | from game.nodes.bullet import ( 7 | BulletBoxShape, 8 | BulletCapsuleShape, 9 | BulletDebugNode, 10 | BulletPlaneShape, 11 | BulletRigidBodyNode, 12 | BulletSphereShape, 13 | BulletWorld, 14 | ) 15 | from game.nodes.nongraphobject import Metaobject 16 | from pandaEditor.nodes.tests.testbasemixin import TestBaseMixin 17 | from pandaEditor.nodes.tests.test_nodepath import TestNodePathMixin 18 | 19 | 20 | class TestBulletBoxShape(TestBaseMixin, unittest.TestCase): 21 | 22 | component = BulletBoxShape 23 | create_kwargs = { 24 | 'halfExtents': pc.Vec3(0.5, 0.5, 0.5), 25 | } 26 | 27 | 28 | class TestBulletCapsuleShape(TestBaseMixin, unittest.TestCase): 29 | 30 | component = BulletCapsuleShape 31 | create_kwargs = { 32 | 'radius': 0.5, 33 | 'height': 1, 34 | 'up': pb.ZUp, 35 | } 36 | 37 | 38 | class TestBulletDebugNode(TestNodePathMixin, unittest.TestCase): 39 | 40 | component = BulletDebugNode 41 | 42 | 43 | class TestBulletPlaneShape(TestBaseMixin, unittest.TestCase): 44 | 45 | component = BulletPlaneShape 46 | create_kwargs = { 47 | 'normal': pc.Vec3(0, 0, 1), 48 | 'point': pc.Vec3(0, 0, 0), 49 | } 50 | 51 | 52 | class TestBulletRigidBodyNode(TestBaseMixin, unittest.TestCase): 53 | 54 | component = BulletRigidBodyNode 55 | 56 | def test_append_shape(self): 57 | rigid, shape = pc.NodePath(pb.BulletRigidBodyNode()), pb.BulletSphereShape(1) 58 | rigid_comp, shape_comp = BulletRigidBodyNode(rigid), BulletSphereShape(shape) 59 | self.base.scene.objects[shape_comp.data] = Metaobject() 60 | rigid_comp.shapes.append(shape_comp) 61 | self.assertEqual(shape, rigid.node().get_shapes()[0]) 62 | 63 | def test_remove_solid(self): 64 | rigid, shape = pc.NodePath(pb.BulletRigidBodyNode()), pb.BulletSphereShape(1) 65 | rigid.node().add_shape(shape) 66 | rigid_comp, shape_comp = BulletRigidBodyNode(rigid), BulletSphereShape(shape) 67 | rigid_comp.shapes.remove(shape_comp) 68 | self.assertEqual(0, len(rigid.node().get_shapes())) 69 | 70 | 71 | class TestBulletSphereShape(TestBaseMixin, unittest.TestCase): 72 | 73 | component = BulletSphereShape 74 | create_kwargs = { 75 | 'radius': 0.5, 76 | } 77 | 78 | 79 | class TestBulletWorld(TestBaseMixin, unittest.TestCase): 80 | 81 | component = BulletWorld 82 | 83 | def test_create(self): 84 | super().test_create() 85 | self.assertIsNotNone(get_base().scene.physics_world) 86 | 87 | def test_set_debug_node(self): 88 | world, debug = pb.BulletWorld(), pc.NodePath(pb.BulletDebugNode()) 89 | world_comp, debug_comp = BulletWorld(world), BulletDebugNode(debug) 90 | world_comp.debug_node = debug_comp 91 | self.assertEqual(debug.node(), world.get_debug_node()) 92 | 93 | def test_append_rigid_body(self): 94 | world, rigid = pb.BulletWorld(), pc.NodePath(pb.BulletRigidBodyNode()) 95 | world_comp, rigid_comp = BulletWorld(world), BulletRigidBodyNode(rigid) 96 | world_comp.rigid_bodies.append(rigid_comp) 97 | self.assertEqual(rigid.node(), world.get_rigid_bodies()[0]) 98 | 99 | def test_remove_rigid_body(self): 100 | world, rigid = pb.BulletWorld(), pc.NodePath(pb.BulletRigidBodyNode()) 101 | world.attach(rigid.node()) 102 | world_comp, rigid_comp = BulletWorld(world), BulletRigidBodyNode(rigid) 103 | world_comp.rigid_bodies.remove(rigid_comp) 104 | self.assertEqual(0, len(world.get_rigid_bodies())) 105 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/test_collision.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import panda3d.core as pc 4 | from game.nodes.collision import ( 5 | CollisionBox, 6 | CollisionCapsule, 7 | CollisionInvSphere, 8 | CollisionNode, 9 | CollisionRay, 10 | CollisionSphere, 11 | ) 12 | from game.nodes.nongraphobject import Metaobject 13 | from pandaEditor.nodes.tests.test_nodepath import ( 14 | TestNodePathMixin, 15 | ) 16 | from pandaEditor.nodes.tests.testbasemixin import TestBaseMixin 17 | 18 | 19 | class TestCollisionBox(TestBaseMixin, unittest.TestCase): 20 | 21 | component = CollisionBox 22 | create_kwargs = { 23 | 'min': pc.Point3(-0.5, -0.5, -0.5), 24 | 'max': pc.Point3(0.5, 0.5, 0.5), 25 | } 26 | 27 | 28 | class TestCollisionCapsule(TestBaseMixin, unittest.TestCase): 29 | 30 | component = CollisionCapsule 31 | create_kwargs = { 32 | 'a': pc.Point3(0), 33 | 'db': pc.Point3(0, 0, 1), 34 | 'radius': 0.5, 35 | } 36 | 37 | 38 | class TestCollisionNode(TestNodePathMixin, unittest.TestCase): 39 | 40 | component = CollisionNode 41 | 42 | def test_append_solid(self): 43 | collision = pc.NodePath(pc.CollisionNode('collision_node')) 44 | solid = pc.CollisionBox(pc.Point3(0, 0, 0), 1, 1, 1) 45 | node_comp, solid_comp = CollisionNode(collision), CollisionBox(solid) 46 | self.base.scene.objects[solid_comp.data] = Metaobject() 47 | node_comp.solids.append(solid_comp) 48 | self.assertEqual(solid, collision.node().get_solids()[0]) 49 | 50 | def test_remove_solid(self): 51 | collision = pc.NodePath(pc.CollisionNode('collision_node')) 52 | solid = pc.CollisionBox(pc.Point3(0, 0, 0), 1, 1, 1) 53 | collision.node().add_solid(solid) 54 | node_comp, solid_comp = CollisionNode(collision), CollisionBox(solid) 55 | node_comp.solids.remove(solid_comp) 56 | self.assertEqual(0, len(collision.node().get_solids())) 57 | 58 | 59 | class TestCollisionRay(TestBaseMixin, unittest.TestCase): 60 | 61 | component = CollisionRay 62 | create_kwargs = { 63 | 'origin': pc.Point3(0), 64 | 'direction': pc.Vec3(0, 0, 1), 65 | } 66 | 67 | 68 | class TestCollisionSphere(TestBaseMixin, unittest.TestCase): 69 | 70 | component = CollisionSphere 71 | create_kwargs = { 72 | 'center': pc.Point3(0), 73 | 'radius': 0.5, 74 | } 75 | 76 | 77 | class TestCollisionInvSphere(TestCollisionSphere): 78 | 79 | component = CollisionInvSphere 80 | create_kwargs = { 81 | 'center': pc.Point3(0), 82 | 'radius': 0.5, 83 | } 84 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/test_lights.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pandaEditor.nodes.tests.test_nodepath import ( 4 | TestNodePathMixin, 5 | ) 6 | from game.nodes.lights import ( 7 | Light, 8 | AmbientLight, 9 | DirectionalLight, 10 | PointLight, 11 | Spotlight 12 | ) 13 | 14 | 15 | class TestLightMixin(TestNodePathMixin): 16 | 17 | component = Light 18 | 19 | def test_create(self): 20 | comp = super().test_create() 21 | self.assertEqual('node', comp.name) 22 | self.assertEqual((1, 1, 1, 1), comp.color_scale) 23 | 24 | 25 | class TestAmbientLight(TestLightMixin, unittest.TestCase): 26 | 27 | component = AmbientLight 28 | 29 | 30 | class TestDirectionalLight(TestLightMixin, unittest.TestCase): 31 | 32 | component = DirectionalLight 33 | 34 | 35 | class TestPointLight(TestLightMixin, unittest.TestCase): 36 | 37 | component = PointLight 38 | 39 | 40 | class TestSpotlight(TestLightMixin, unittest.TestCase): 41 | 42 | component = Spotlight 43 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/test_nodepath.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from pandaEditor.nodes.tests.testbasemixin import ( 4 | TestBaseMixin, 5 | ) 6 | from game.nodes.fog import Fog 7 | from game.nodes.lights import AmbientLight 8 | from game.nodes.nodepath import NodePath 9 | 10 | 11 | class TestNodePathMixin(TestBaseMixin): 12 | 13 | create_kwargs = {'name': 'node'} 14 | 15 | def test_create(self): 16 | comp = super().test_create() 17 | self.assertFalse(comp.lights) 18 | self.assertIsNone(comp.fog) 19 | return comp 20 | 21 | def test_set_name(self): 22 | panda = pc.NodePath(pc.PandaNode('panda_node')) 23 | NodePath(panda).name = 'new name' 24 | self.assertEqual('new name', panda.get_name()) 25 | 26 | def test_set_fog(self): 27 | panda = pc.NodePath(pc.PandaNode('panda_node')) 28 | fog = pc.NodePath(pc.Fog('fog')) 29 | panda_comp, fog_comp = NodePath(panda), Fog(fog) 30 | panda_comp.fog = fog_comp 31 | self.assertEqual(fog.node(), panda.get_fog()) 32 | 33 | def test_clear_fog(self): 34 | panda = pc.NodePath(pc.PandaNode('panda_node')) 35 | fog = pc.NodePath(pc.Fog('fog')) 36 | panda.set_fog(fog.node()) 37 | panda_comp = NodePath(panda) 38 | panda_comp.fog = None 39 | self.assertIsNone(panda.get_fog()) 40 | 41 | def test_append_light(self): 42 | panda = pc.NodePath(pc.PandaNode('panda_node')) 43 | light = pc.NodePath(pc.AmbientLight('ambient_light')) 44 | panda_comp, light_comp = NodePath(panda), AmbientLight(light) 45 | panda_comp.lights.append(light_comp) 46 | lights = panda.get_attrib(pc.LightAttrib).get_on_lights() 47 | self.assertEqual(light.node(), lights[0].node()) 48 | 49 | # def test_append_light2(self): 50 | # panda = pc.NodePath(pc.PandaNode('panda_node')) 51 | # light = pc.NodePath(pc.AmbientLight('ambient_light')) 52 | # panda_comp, light_comp = NodePath(panda), AmbientLight(light) 53 | # panda_comp.lights = [light_comp] 54 | # lights = panda.get_attrib(pc.LightAttrib).get_on_lights() 55 | # self.assertEqual(1, len(lights)) 56 | # self.assertEqual(light.node(), lights[0].node()) 57 | 58 | def test_remove_light(self): 59 | panda = pc.NodePath(pc.PandaNode('panda_node')) 60 | light = pc.NodePath(pc.AmbientLight('ambient_light')) 61 | panda.set_light(light) 62 | panda_comp, light_comp = NodePath(panda), AmbientLight(light) 63 | panda_comp.lights.remove(light_comp) 64 | self.assertIsNone(panda.get_attrib(pc.LightAttrib)) 65 | 66 | # def test_remove_light2(self): 67 | # panda = pc.NodePath(pc.PandaNode('panda_node')) 68 | # light = pc.NodePath(pc.AmbientLight('ambient_light')) 69 | # panda.set_light(light) 70 | # panda_comp, light_comp = NodePath(panda), AmbientLight(light) 71 | # panda_comp.lights[:] = [] 72 | # print(len(panda_comp.lights)) 73 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/test_showbasedefaults.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pandaEditor.nodes.tests.test_nodepath import ( 4 | TestNodePathMixin, 5 | ) 6 | from game.nodes.showbasedefaults import ( 7 | Aspect2d, 8 | BaseCam, 9 | BaseCamera, 10 | Cam2d, 11 | Camera2d, 12 | Pixel2d, 13 | Render, 14 | Render2d, 15 | ) 16 | 17 | 18 | class TestRender(TestNodePathMixin, unittest.TestCase): 19 | 20 | component = Render 21 | 22 | def test_create(self): 23 | node = super().test_create() 24 | self.assertEqual('render', node.name) 25 | self.assertTrue(node.matrix.is_identity()) 26 | 27 | 28 | class TestBaseCam(TestNodePathMixin, unittest.TestCase): 29 | 30 | component = BaseCam 31 | 32 | def test_create(self): 33 | node = super().test_create() 34 | self.assertEqual('cam', node.name) 35 | self.assertTrue(node.matrix.is_identity()) 36 | 37 | 38 | class TestBaseCamera(TestNodePathMixin, unittest.TestCase): 39 | 40 | component = BaseCamera 41 | 42 | def test_create(self): 43 | node = super().test_create() 44 | self.assertEqual('camera', node.name) 45 | self.assertTrue(node.matrix.is_identity()) 46 | 47 | 48 | class TestRender2d(TestNodePathMixin, unittest.TestCase): 49 | 50 | component = Render2d 51 | 52 | def test_create(self): 53 | node = super().test_create() 54 | self.assertEqual('render2d', node.name) 55 | self.assertTrue(node.matrix.is_identity()) 56 | 57 | 58 | class TestAspect2d(TestNodePathMixin, unittest.TestCase): 59 | 60 | component = Aspect2d 61 | 62 | def test_create(self): 63 | node = super().test_create() 64 | self.assertEqual('aspect2d', node.name) 65 | self.assertFalse(node.matrix.is_identity()) 66 | 67 | 68 | class TestPixel2d(TestNodePathMixin, unittest.TestCase): 69 | 70 | component = Pixel2d 71 | 72 | def test_create(self): 73 | node = super().test_create() 74 | self.assertEqual('pixel2d', node.name) 75 | self.assertFalse(node.matrix.is_identity()) 76 | 77 | 78 | class TestCamera2d(TestNodePathMixin, unittest.TestCase): 79 | 80 | component = Camera2d 81 | 82 | def test_create(self): 83 | node = super().test_create() 84 | self.assertEqual('camera2d', node.name) 85 | self.assertTrue(node.matrix.is_identity()) 86 | 87 | 88 | class TestCam2d(TestNodePathMixin, unittest.TestCase): 89 | 90 | component = Cam2d 91 | 92 | def test_create(self): 93 | node = super().test_create() 94 | self.assertEqual('cam2d', node.name) 95 | self.assertTrue(node.matrix.is_identity()) 96 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/testbasemixin.py: -------------------------------------------------------------------------------- 1 | from pandaEditor.nodes.tests.testmixin import TestMixin 2 | 3 | 4 | class TestBaseMixin(TestMixin): 5 | 6 | component = None 7 | create_kwargs = {} 8 | 9 | def test_create(self): 10 | 11 | # TODO: Put this in a utility method, not an actual test. 12 | comp = self.component.create(**self.create_kwargs) 13 | comp.parent = comp.default_parent 14 | comp.set_default_values() 15 | self.assertTrue(isinstance(comp, self.component)) 16 | return comp 17 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/tests/testmixin.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | from direct.showbase import ShowBaseGlobal 3 | from direct.showbase.PythonUtil import getBase as get_base 4 | 5 | from pandaEditor.scene import Scene 6 | from pandaEditor.game.showbase import ShowBase 7 | 8 | 9 | class TestMixin: 10 | 11 | def setUp(self): 12 | try: 13 | self.base = get_base() 14 | except NameError: 15 | self.base = ShowBase() 16 | self.base.scene = Scene() 17 | 18 | def tearDown(self): 19 | """Remove all default nodes and recreate them.""" 20 | # Remove all default nodes and set them to None so they are recreated 21 | # properly. 22 | for name in ('cam', 'camera', 'cam2d', 'camera2d'): 23 | np = getattr(self.base, name) 24 | np.removeNode() 25 | setattr(self.base, name, None) 26 | 27 | # Set up render and render2d again, forcing their new values into 28 | # builtins. 29 | self.base.setupRender() 30 | 31 | # This is kinda lame imho. These default nodes are created by importing 32 | # the showbase global module, which makes it difficult to recreate these 33 | # nodes for our purposes. 34 | render2d = pc.NodePath('render2d') 35 | aspect2d = render2d.attachNewNode(pc.PGTop('aspect2d')) 36 | ShowBaseGlobal.render2d = render2d 37 | ShowBaseGlobal.aspect2d = aspect2d 38 | self.base.setupRender2d() 39 | 40 | __builtins__['render'] = self.base.render 41 | __builtins__['render2d'] = self.base.render2d 42 | __builtins__['aspect2d'] = self.base.aspect2d 43 | __builtins__['pixel2d'] = self.base.pixel2d 44 | 45 | self.base.makeCamera(self.base.win) 46 | self.base.makeCamera2d(self.base.win) 47 | __builtins__['camera'] = self.base.camera 48 | 49 | # Set auto shader. 50 | self.base.render.setShaderAuto() 51 | -------------------------------------------------------------------------------- /src/pandaEditor/nodes/texture.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | 3 | from game.nodes.attributes import ProjectAssetAttribute 4 | 5 | 6 | class Texture: 7 | 8 | fullpath = ProjectAssetAttribute( 9 | pc.Filename, 10 | pc.Texture.get_fullpath, 11 | required=True, 12 | ) 13 | 14 | @property 15 | def label(self): 16 | return self.fullpath 17 | -------------------------------------------------------------------------------- /src/pandaEditor/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/plugins/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/plugins/base.py: -------------------------------------------------------------------------------- 1 | from game.plugins.ibase import IBase 2 | 3 | 4 | class Base(IBase): 5 | 6 | def on_update(self, comps): 7 | pass 8 | 9 | def on_scene_close(self): 10 | pass 11 | 12 | def on_project_modified(self, file_paths): 13 | pass 14 | 15 | def on_build_ui(self): 16 | pass 17 | -------------------------------------------------------------------------------- /src/pandaEditor/plugins/manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from game.plugins.base import Base as GamePluginBase 4 | from game.plugins.manager import Manager as GameManager 5 | from pandaEditor.plugins.base import Base as EditorPluginBase 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Manager(GameManager): 12 | 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | self.setCategoriesFilter({ 16 | 'editor': EditorPluginBase, 17 | 'game': GamePluginBase, 18 | }) 19 | 20 | def getPluginsOfCategory(self, category_name): 21 | try: 22 | return super().getPluginsOfCategory(category_name) 23 | except KeyError: 24 | logger.error('Failed to resolve plugin categories', exc_info=True) 25 | return [] 26 | 27 | def on_init(self): 28 | super().on_init() 29 | for plugin in self.getAllPlugins(): 30 | logger.info(f'Loaded plugin: {plugin.name}') 31 | 32 | def on_update(self, comps): 33 | for plugin in self.getPluginsOfCategory('editor'): 34 | plugin.plugin_object.on_update(comps) 35 | 36 | def on_scene_close(self): 37 | for plugin in self.getPluginsOfCategory('editor'): 38 | plugin.plugin_object.on_scene_close() 39 | 40 | def on_project_modified(self, file_paths): 41 | for plugin in self.getPluginsOfCategory('editor'): 42 | plugin.plugin_object.on_project_modified(file_paths) 43 | 44 | def on_build_ui(self): 45 | for plugin in self.getPluginsOfCategory('editor'): 46 | plugin.plugin_object.on_build_ui() 47 | 48 | def on_register_component(self): 49 | for plugin in self.getPluginsOfCategory('game'): 50 | plugin.plugin_object.on_register_component() 51 | -------------------------------------------------------------------------------- /src/pandaEditor/scene.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import logging 3 | 4 | from direct.showbase.PythonUtil import getBase as get_base 5 | 6 | from pandaEditor.game.scene import Scene 7 | from pandaEditor.game.nodes.constants import TAG_NODE_TYPE 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Scene(Scene): 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | # TODO: Move this to component metaobject? 19 | self.connections = {} 20 | 21 | # 'Create' the default NodePaths that come from showbase. Calling the 22 | # create method in this way doesn't generate any new NodePaths, it 23 | # will simply return those the default showbase creates when it starts 24 | # up. Tag them so their type is overriden and the component manager 25 | # wraps them appropriately. 26 | # TODO: Move this to init_empty_scene so it won't run twice on scene 27 | # load. 28 | defaultCompTypes = [ 29 | 'Render', 30 | 'BaseCamera', 31 | 'BaseCam', 32 | 'Render2d', 33 | 'Aspect2d', 34 | 'Pixel2d', 35 | 'Camera2d', 36 | 'Cam2d' 37 | ] 38 | for cType in defaultCompTypes: 39 | comp_cls = get_base().node_manager.wrappers[cType] 40 | comp = comp_cls.create() 41 | 42 | # TODO: Move the tag setting into the actual component. 43 | comp.data.set_tag(TAG_NODE_TYPE, cType) 44 | comp.id = str(uuid.uuid4()) 45 | 46 | def load(self, file_path): 47 | """Recreate a scene graph from file.""" 48 | get_base().scene_parser.load(file_path) 49 | 50 | def save(self, file_path): 51 | """Save a scene graph to file.""" 52 | get_base().scene_parser.save(self, file_path) 53 | 54 | def close(self): 55 | """Destroy the scene by removing all its components.""" 56 | def destroy(comp): 57 | for child in comp.children: 58 | destroy(child) 59 | comp.destroy() 60 | 61 | destroy(get_base().node_manager.wrap(self)) 62 | get_base().plugin_manager.on_scene_close() 63 | 64 | # Now remove the root node. If the root node was render, reset base 65 | # in order to remove and recreate the default node set. 66 | if self.rootNp is get_base().render: 67 | get_base().reset() 68 | 69 | self.rootNp.removeNode() 70 | 71 | def get_outgoing_connections(self, comp): 72 | """ 73 | Return all outgoing connections for the indicated component. 74 | 75 | """ 76 | return self.connections.get(comp.id, []) 77 | 78 | def get_incoming_connections(self, comp): 79 | """ 80 | Return all incoming connections for the indicated component wrapper. 81 | 82 | """ 83 | in_connections = [] 84 | for comp_id, connections in self.connections.items(): 85 | for connection in connections: 86 | source, name = connection 87 | if source == comp.data: 88 | in_connections.append(connection) 89 | return in_connections 90 | 91 | def register_connection(self, source, target, name): 92 | """ 93 | Register a connection to its target component. This allows us to find 94 | a connection and break it when a component is deleted. 95 | 96 | """ 97 | logger.info(f'Registered {name} connection: {source} -> {target}') 98 | self.connections.setdefault(target.id, set()).add((source, name)) 99 | 100 | def deregister_connection(self, connection): 101 | comp = connection.parent 102 | comp_id = get_base().node_manager.wrap(comp).id 103 | if comp_id in self.connections: 104 | del self.connections[comp_id] 105 | -------------------------------------------------------------------------------- /src/pandaEditor/sceneparser.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | 4 | import xml.etree.ElementTree as et 5 | from direct.showbase.PythonUtil import getBase as get_base 6 | 7 | from p3d.commonUtils import serialise 8 | from game.sceneparser import SceneParser as GameSceneParser 9 | from utils import indent 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class SceneParser(GameSceneParser): 16 | 17 | def save(self, obj, file_path): 18 | """Save the scene out to an xml file.""" 19 | comp = get_base().node_manager.wrap(obj) 20 | relem = self.save_component(comp, None) 21 | 22 | # Wrap with an element tree and write to file. 23 | tree = et.ElementTree(relem) 24 | indent(tree.getroot()) 25 | tree.write(file_path) 26 | 27 | def save_component(self, comp, pelem): 28 | """Serialise a component to an xml element.""" 29 | elem = pelem 30 | if comp.savable: 31 | 32 | # Write out component header data, then properties and 33 | # connections. 34 | elem = et.Element('Component') 35 | if pelem is not None: 36 | pelem.append(elem) 37 | for name, value in comp.get_attrib().items(): 38 | elem.set(name, value) 39 | self.save_properties(comp, elem) 40 | self.save_connections(comp, elem) 41 | 42 | # Recurse through hierarchy. 43 | if comp.serialise_descendants: 44 | for child in comp.children: 45 | self.save_component(child, elem) 46 | 47 | return elem 48 | 49 | def save_properties(self, comp, elem): 50 | """ 51 | Get a dictionary representing all the properties for the component 52 | then serialise it. 53 | """ 54 | 55 | # TODO: Move to attributes property on comp class like connections, 56 | # below. 57 | for attr_name, attr in comp.__class__.attributes.items(): 58 | if not attr.serialise: 59 | continue 60 | value = getattr(comp, attr_name) 61 | if value is None: 62 | logger.warning(f'Skipped serialising None value: {attr_name}') 63 | continue 64 | item_elem = et.SubElement(elem, 'Item') 65 | item_elem.set('name', attr_name) 66 | item_elem.set('value', serialise(value)) 67 | 68 | def save_connections(self, comp, elem): 69 | 70 | conns_elem = et.Element('Connections') 71 | for name, values in comp.connections.items(): 72 | if not comp.__class__.connections[name].many: 73 | values = [values] 74 | for value in values: 75 | conn_elem = et.SubElement(conns_elem, 'Connection') 76 | conn_elem.set('type', name) 77 | conn_elem.set('value', value.id) 78 | 79 | # Append the connections element only if it isn't empty. 80 | if list(conns_elem): 81 | elem.append(conns_elem) 82 | -------------------------------------------------------------------------------- /src/pandaEditor/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Derfies/panda3d-editor/9043a0253a81b3d749566389b1ee55a9e9f8e61a/src/pandaEditor/tests/__init__.py -------------------------------------------------------------------------------- /src/pandaEditor/tests/test_actions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | 4 | import panda3d.core as pc 5 | 6 | from actions import Add, Transform, SetAttribute, Parent 7 | from game.nodes.lights import AmbientLight 8 | from game.nodes.nodepath import NodePath 9 | from game.nodes.showbasedefaults import Render 10 | from pandaEditor.nodes.tests.testmixin import TestMixin 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class TestActions(TestMixin, unittest.TestCase): 17 | 18 | def test_parent(self): 19 | parent = pc.NodePath(pc.PandaNode('parent')) 20 | old_parent = pc.NodePath(pc.PandaNode('old_parent')) 21 | child = pc.NodePath(pc.PandaNode('child')) 22 | child.reparent_to(old_parent) 23 | parent_comp = NodePath(parent) 24 | old_parent_comp = NodePath(old_parent) 25 | child_comp = NodePath(child) 26 | action = Parent(child_comp, parent_comp) 27 | 28 | action.redo() 29 | self.assertEqual(parent, child.get_parent()) 30 | 31 | action.undo() 32 | self.assertEqual(old_parent, child.get_parent()) 33 | 34 | 35 | def test_add_remove(self): 36 | light = pc.NodePath(pc.AmbientLight('ambient_light')) 37 | render = self.base.render 38 | light_comp, render_comp = AmbientLight(light), Render(render) 39 | action = Add(light_comp) 40 | action.pcomp = render_comp 41 | action.id = 'id' 42 | action.connections = [(render_comp, 'lights')] 43 | 44 | action.redo() 45 | self.assertEqual(render, light.get_parent()) 46 | lights = render.get_attrib(pc.LightAttrib).get_on_lights() 47 | self.assertEqual(light.node(), lights[0].node()) 48 | 49 | action.undo() 50 | self.assertTrue(light.get_parent().is_empty()) 51 | lights = render.get_attrib(pc.LightAttrib) 52 | self.assertIsNone(lights) 53 | 54 | def test_transform(self): 55 | panda = pc.NodePath(pc.PandaNode('panda_node')) 56 | panda_comp = NodePath(panda) 57 | pos = pc.Point3(1, 0, 0) 58 | old_pos = pc.Point3(0, 0, 0) 59 | xform = pc.TransformState.make_pos(pos) 60 | old_xform = pc.TransformState.make_pos(old_pos) 61 | action = Transform(panda_comp, xform, old_xform) 62 | 63 | action.redo() 64 | self.assertEqual(pos, panda.get_pos()) 65 | 66 | action.undo() 67 | self.assertEqual(old_pos, panda.get_pos()) 68 | 69 | def test_set_attribute(self): 70 | panda = pc.NodePath(pc.PandaNode('panda_node')) 71 | panda_comp = NodePath(panda) 72 | action = SetAttribute(panda_comp, 'name', 'new_name') 73 | 74 | action.redo() 75 | self.assertEqual('new_name', panda.get_name()) 76 | 77 | action.undo() 78 | self.assertEqual('panda_node', panda.get_name()) 79 | -------------------------------------------------------------------------------- /src/pandaEditor/tests/test_project.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import stat 5 | import tempfile 6 | import unittest 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Test(unittest.TestCase): 13 | 14 | @classmethod 15 | def setUpClass(cls): 16 | super(Test, cls).setUpClass() 17 | 18 | # Create working directory. 19 | cls.temp_dir_path = tempfile.mkdtemp() 20 | logger.info('Created working directory: {}'.format(cls.temp_dir_path)) 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | super(Test, cls).tearDownClass() 25 | 26 | # Delete working directory. 27 | def del_rw(action, name, exc): 28 | os.chmod(name, stat.S_IWRITE) 29 | os.remove(name) 30 | 31 | shutil.rmtree(cls.temp_dir_path, onerror=del_rw) 32 | logger.info('Deleted working directory: {}'.format(cls.temp_dir_path)) 33 | 34 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from .document import Document 2 | from .mainFrame import MainFrame 3 | from .viewport import Viewport 4 | from .projectSettingsPanel import ProjectSettingsPanel 5 | from .propertiesPanel import PropertiesPanel 6 | from .resourcesPanel import ResourcesPanel 7 | from .sceneGraphBasePanel import SceneGraphBasePanel 8 | from .sceneGraphPanel import SceneGraphPanel 9 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/createdialog.py: -------------------------------------------------------------------------------- 1 | import panda3d.core as pc 2 | import wx 3 | from wx.lib.agw.floatspin import FloatSpin 4 | from wx.lib.intctrl import IntCtrl 5 | 6 | from pandaEditor.utils import camel_case_to_label 7 | from wxExtra.propertyGrid import FloatValidator 8 | 9 | 10 | class BoolCtrl(wx.CheckBox): 11 | 12 | def __init__(self, *args, **kwargs): 13 | value = kwargs.pop('value') 14 | super().__init__(*args, **kwargs) 15 | if value: 16 | self.SetValue(value) 17 | 18 | 19 | class PropFloatSpin(FloatSpin): 20 | 21 | def __init__(self, *args, **kwargs): 22 | super().__init__(*args, digits=2, **kwargs) 23 | 24 | 25 | class BaseCtrl(wx.Control): 26 | 27 | type_ = pc.Vec3 28 | 29 | def __init__(self, *args, **kwargs): 30 | values = kwargs.pop('value') 31 | super().__init__(*args, **kwargs) 32 | 33 | self._mainsizer = wx.BoxSizer(wx.HORIZONTAL) 34 | self._text_ctrls = [] 35 | for i in range(3): 36 | text_ctrl = wx.TextCtrl( 37 | self, 38 | wx.ID_ANY, 39 | str(values[i]), 40 | validator=FloatValidator(), 41 | style=wx.TE_PROCESS_ENTER, 42 | ) 43 | text_ctrl.SetInitialSize(wx.Size(60, -1)) 44 | self._text_ctrls.append(text_ctrl) 45 | self._mainsizer.Add(text_ctrl, 1) 46 | self.SetSizer(self._mainsizer) 47 | self._mainsizer.Layout() 48 | 49 | def GetValue(self): 50 | return self.type_(*[float(ctrl.GetValue()) for ctrl in self._text_ctrls]) 51 | 52 | 53 | class Vec3Ctrl(BaseCtrl): 54 | 55 | type_ = pc.Vec3 56 | 57 | 58 | class Point3Ctrl(BaseCtrl): 59 | 60 | type_ = pc.Point3 61 | 62 | 63 | PROPERTY_MAP = { 64 | bool: BoolCtrl, 65 | int: IntCtrl, 66 | float: PropFloatSpin, 67 | str: wx.TextCtrl, 68 | pc.Point3: Point3Ctrl, 69 | pc.Vec3: Vec3Ctrl, 70 | } 71 | 72 | 73 | class CreateDialog(wx.Dialog): 74 | 75 | def __init__(self, text, default_values, *args, **kwargs): 76 | super().__init__(*args, **kwargs) 77 | 78 | self.ctrls = {} 79 | 80 | static_text = wx.StaticText(self, -1, text) 81 | sizer = wx.BoxSizer(wx.VERTICAL) 82 | sizer.Add(static_text, 0, wx.TOP | wx.LEFT | wx.RIGHT, 10) 83 | 84 | for name, value in default_values.items(): 85 | value_type = type(value) 86 | hsizer = wx.BoxSizer(wx.HORIZONTAL) 87 | label = camel_case_to_label(name) 88 | hsizer.Add(wx.StaticText(self, -1, label), 1) 89 | ctrl_cls = PROPERTY_MAP[value_type] 90 | ctrl = ctrl_cls(self, -1, value=value) 91 | self.ctrls[name] = ctrl 92 | hsizer.Add(ctrl, 1) 93 | sizer.Add(hsizer, 1, wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, 10) 94 | 95 | buttons = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL) 96 | sizer.Add(buttons, 0, wx.EXPAND | wx.ALL, 10) 97 | self.SetSizerAndFit(sizer) 98 | 99 | def GetValues(self): 100 | return { 101 | name: ctrl.GetValue() 102 | for name, ctrl in self.ctrls.items() 103 | } 104 | 105 | def Validate(self): 106 | result = super().Validate() 107 | if result: 108 | self.EndModal(wx.ID_OK) 109 | return result 110 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/document.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from direct.showbase.PythonUtil import getBase as get_base 4 | from pubsub import pub 5 | 6 | 7 | class Document: 8 | 9 | def __init__(self, file_path, contents): 10 | self.file_path = file_path 11 | self.contents = contents 12 | 13 | self.dirty = False 14 | 15 | @property 16 | def title(self): 17 | if self.file_path is not None: 18 | return os.path.basename(self.file_path) 19 | else: 20 | return 'untitled' 21 | 22 | def load(self): 23 | self.contents.load(self.file_path) 24 | self.on_refresh() 25 | 26 | def save(self, **kwargs): 27 | file_path = kwargs.pop('file_path', self.file_path) 28 | self.contents.save(file_path) 29 | self.dirty = False 30 | self.on_refresh() 31 | 32 | def on_refresh(self, comps=None): 33 | """ 34 | Broadcast the update message without setting the dirty flag. Methods 35 | subscribed to this message will rebuild ui widgets completely. 36 | """ 37 | pub.sendMessage('Update', comps=comps) 38 | 39 | def on_modified(self, comps=None): 40 | """ 41 | Broadcast the update message and set the dirty flag. Methods 42 | subscribed to this message will rebuild ui widgets completely. 43 | """ 44 | self.dirty = True 45 | pub.sendMessage('Update', comps=comps) 46 | 47 | def on_selection_modified(self, task): 48 | """ 49 | Broadcast the update selection message. Methods subscribed to this 50 | message should be quick and not force full rebuilds of ui widgets 51 | considering how quickly the selection is likely to change. 52 | """ 53 | pub.sendMessage('SelectionModified', comps=get_base().selection.comps) 54 | return task.cont 55 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/lightLinkerPanel.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.lib.agw.customtreectrl as ct 3 | 4 | import panda3d.core as pm 5 | from panda3d.core import NodePath as NP 6 | 7 | from pandaEditor import commands as cmds 8 | from game.nodes.attributes import Attribute as Attr 9 | from .sceneGraphBasePanel import SceneGraphBasePanel 10 | 11 | 12 | class LightLinkerPanel(wx.Panel): 13 | 14 | def __init__(self, base, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | self.base = base 18 | 19 | # Build splitter and panels 20 | self.splt = wx.SplitterWindow(self, style=wx.SP_3DSASH) 21 | self.splt.SetSashGravity(0.5) 22 | 23 | self.pnlLeft = SceneGraphBasePanel(self.base, self.splt) 24 | flags = self.pnlLeft.tc.GetAGWWindowStyleFlag() 25 | self.pnlLeft.tc.SetAGWWindowStyleFlag(flags & ~ct.TR_MULTIPLE) 26 | self.pnlLeft.filter = pm.Light 27 | 28 | self.pnlRight = SceneGraphBasePanel(self.base, self.splt) 29 | 30 | # Split the window 31 | self.splt.SplitVertically(self.pnlLeft, self.pnlRight) 32 | self.splt.SetMinimumPaneSize(20) 33 | 34 | sizer = wx.BoxSizer(wx.VERTICAL) 35 | sizer.Add(self.splt, 1, wx.EXPAND) 36 | self.SetSizer(sizer) 37 | 38 | # Bind tree control events 39 | self.pnlLeft.tc.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnLeftTreeSelChanged) 40 | self.pnlRight.tc.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnRightTreeSelChanged) 41 | 42 | def OnLeftTreeSelChanged(self, evt): 43 | """ 44 | Select those items in the right list to show the relationship with 45 | those the user has selected in the left list. 46 | """ 47 | self.pnlLeft.tc.Freeze() 48 | 49 | self.pnlRight.tc.UnselectAll() 50 | items = self.pnlLeft.tc.GetSelections() 51 | if items: 52 | light = items[0].GetData() 53 | 54 | npItems = [] 55 | rItem = self.pnlRight.tc.GetRootItem() 56 | for item in self.pnlRight.tc.GetItemChildren(rItem, True): 57 | attrib = item.GetData().getAttrib(pm.LightAttrib) 58 | if attrib is not None and light in attrib.getOnLights(): 59 | npItems.append(item) 60 | 61 | self.pnlRight.SelectItems(npItems) 62 | 63 | self.pnlLeft.tc.Thaw() 64 | 65 | def OnRightTreeSelChanged(self, evt): 66 | """ 67 | Set relationship with those items selected in the left list with those 68 | the user has selected in the right list. 69 | """ 70 | # Get the light NodePath selected in the left panel 71 | items = self.pnlLeft.GetValidSelections() 72 | if not items: 73 | self.pnlRight.tc.UnselectAll() 74 | return 75 | lgtNp = items[0].GetData() 76 | 77 | # Get the light attribute for the NodePath. Create an empty one if 78 | # None is returned (has not had any lights applied yet). 79 | item = evt.GetItem() 80 | np = item.GetData() 81 | attrib = np.getAttrib(pm.LightAttrib) 82 | if attrib is None: 83 | 84 | # Set an empty light attrib so this works with the undo system. 85 | attrib = pm.LightAttrib.make() 86 | np.setAttrib(attrib) 87 | 88 | # Add / remove lights from the attrib. 89 | if item in self.pnlRight.tc.GetSelections(): 90 | attrib = attrib.addOnLight(lgtNp) 91 | else: 92 | attrib = attrib.removeOnLight(lgtNp) 93 | 94 | # Construct the attribute and set it. 95 | attr = Attr('OnLight', pm.LightAttrib, NP.getAttrib, 96 | NP.setAttrib, getArgs=[pm.LightAttrib]) 97 | cmds.set_attribute([np], attr, attrib) 98 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/preferenceseditor.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | 4 | class GeneralPage(wx.StockPreferencesPage): 5 | 6 | def CreateWindow(self, parent): 7 | panel = wx.Panel(parent) 8 | panel.SetMinSize((600, 300)) 9 | 10 | sizer = wx.BoxSizer(wx.VERTICAL) 11 | sizer.Add(wx.StaticText(panel, -1, "general page"), 12 | wx.SizerFlags(1).TripleBorder()) 13 | panel.SetSizer(sizer) 14 | return panel 15 | 16 | 17 | class PreferencesEditor(wx.PreferencesEditor): 18 | 19 | def __init__(self): 20 | super().__init__('Preferences') 21 | 22 | class MyPrefsPanel(wx.Panel): 23 | def __init__(self, parent): 24 | wx.Panel.__init__(self, parent) 25 | cb1 = wx.CheckBox(self, -1, "Option 1") 26 | cb2 = wx.CheckBox(self, -1, "Option 2") 27 | box = wx.BoxSizer(wx.VERTICAL) 28 | box.Add(cb1, 0, wx.EXPAND) 29 | box.Add(cb2, 0, wx.EXPAND) 30 | self.Sizer = wx.BoxSizer() 31 | self.Sizer.Add(box, 0, wx.EXPAND) 32 | 33 | class MyPrefsPage(wx.PreferencesPage): 34 | def GetName(self): 35 | return 'MyPrefsPage' 36 | 37 | def GetLargeIcon(self): 38 | return wx.ArtProvider.GetBitmap(wx.ART_HELP, wx.ART_TOOLBAR, 39 | (32, 32)) 40 | 41 | def CreateWindow(self, parent): 42 | return MyPrefsPanel(parent) 43 | 44 | page = MyPrefsPage() 45 | self.AddPage(page) 46 | 47 | page = GeneralPage(0) 48 | self.AddPage(page) 49 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/projectSettingsPanel.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | 4 | class ProjectSettingsPanel(wx.Panel): 5 | 6 | def __init__(self, *args, **kwargs): 7 | super().__init__(*args, **kwargs) 8 | 9 | # Project directory 10 | bs1 = wx.BoxSizer(wx.HORIZONTAL) 11 | bs1.Add(wx.StaticText(self, -1, 'Project Directory:'), 1, wx.RIGHT, 10) 12 | bs1.Add(wx.TextCtrl(self, -1, '', size=(125, -1)), 1, wx.RIGHT, 10) 13 | bs1.Add(wx.Button(self, -1), 1) 14 | 15 | # Project name 16 | bs2 = wx.BoxSizer(wx.HORIZONTAL) 17 | bs2.Add(wx.StaticText(self, -1, 'Project Name:'), 1, wx.RIGHT, 10) 18 | bs2.Add(wx.TextCtrl(self, -1, '', size=(125, -1)), 1, wx.RIGHT, 10) 19 | bs2.Add(wx.Button(self, -1, '...'), 1) 20 | 21 | # Build sizers 22 | bs = wx.BoxSizer(wx.VERTICAL) 23 | bs.Add(bs1, 0) 24 | bs.Add(bs2, 0) 25 | 26 | # Build border 27 | self.border = wx.BoxSizer(wx.VERTICAL) 28 | self.border.Add(bs, 1, wx.ALL, 10) 29 | self.SetSizer(self.border) 30 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/sceneGraphPanel.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from direct.showbase.PythonUtil import getBase as get_base 4 | 5 | from dragdroptarget import DragDropTarget 6 | from game.nodes.nodepath import NodePath 7 | from pandaEditor import commands as cmds 8 | from pandaEditor.ui.sceneGraphBasePanel import SceneGraphBasePanel 9 | from wxExtra import utils as wxutils 10 | 11 | 12 | class SceneGraphPanel(SceneGraphBasePanel): 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | self.tc.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnTreeSelChanged) 18 | self.tc.Bind(wx.EVT_RIGHT_UP, self.on_right_up) 19 | 20 | # Build tree control drop target. 21 | dt = DragDropTarget( 22 | self.drag_drop_validate, 23 | self.on_drop 24 | ) 25 | self.tc.SetDropTarget(dt) 26 | 27 | def on_right_up(self, evt): 28 | 29 | # Get the item under the mouse - bail if the item is not ok 30 | item = wxutils.get_clicked_item(self.tc, evt) 31 | if item is None or not item.IsOk(): 32 | return 33 | 34 | menu = wx.Menu() 35 | m_item = wx.MenuItem(menu, wx.NewId(), 'Sort Children') 36 | menu.Append(m_item) 37 | wxutils.IdBind( 38 | menu, 39 | wx.EVT_MENU, 40 | m_item.GetId(), 41 | self.on_sort_children, 42 | item 43 | ) 44 | self.PopupMenu(menu) 45 | menu.Destroy() 46 | 47 | def on_sort_children(self, evt, item): 48 | 49 | # TODO: Make undoable. 50 | comp = item.GetData() 51 | children = comp.children 52 | for child in children: 53 | child.detach() 54 | for child in sorted(children, key=lambda c: c.label): 55 | comp.add_child(child) 56 | get_base().doc.on_modified() 57 | 58 | def drag_drop_validate(self, x, y, data): 59 | item = self.tc.HitTest(wx.Point(x, y))[0] 60 | drop_ok = item is None or isinstance(item.GetData(), NodePath) 61 | drag_ok = all([isinstance(elem, NodePath) for elem in data]) 62 | return drop_ok and drag_ok 63 | 64 | def on_drop(self, x, y, data): 65 | item = self.tc.HitTest(wx.Point(x, y))[0] 66 | parent = item.GetData() if item is not None else None 67 | cmds.parent(data, parent) 68 | 69 | def OnTreeSelChanged(self, evt): 70 | """ 71 | Tree item selection handler. If the selection of the tree changes, 72 | tell the app to select those components. 73 | 74 | """ 75 | def IndexInSelection(x, comps): 76 | """ 77 | Sort components by their position in the selection, if they appear 78 | there. This will make the new selection order closer to the 79 | original. 80 | 81 | """ 82 | if x in get_base().selection.comps: 83 | i = get_base().selection.comps.index(x) 84 | else: 85 | i = len(get_base().selection.comps) 86 | return i 87 | 88 | items = self.GetValidSelections() 89 | if items: 90 | comps = [item.GetData() for item in items] 91 | comps.sort(key=lambda x: IndexInSelection(x, comps)) 92 | cmds.select(comps) 93 | 94 | def OnUpdate(self, comps=None): 95 | """ 96 | Update the TreeCtrl then hilight those items whose components are 97 | selected. 98 | 99 | """ 100 | self.tc.Freeze() 101 | super().OnUpdate(comps) 102 | 103 | items = [ 104 | self._comps[comp] 105 | for comp in get_base().selection.comps 106 | if comp in self._comps 107 | ] 108 | self.SelectItems(items) 109 | 110 | self.tc.Thaw() 111 | -------------------------------------------------------------------------------- /src/pandaEditor/ui/viewport.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from direct.showbase.PythonUtil import getBase as get_base 5 | 6 | import pandaEditor.commands as cmds 7 | from pandaEditor.dragdroptarget import DragDropTarget 8 | from game.nodes.base import Base 9 | from p3d.wxPanda import Viewport as WxViewport 10 | from wxExtra import CustomMenu, ActionItem 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Viewport(WxViewport): 17 | 18 | def __init__(self, *args, **kwargs): 19 | super().__init__(*args, **kwargs) 20 | 21 | dt = DragDropTarget( 22 | self.drag_drop_validate, 23 | self.on_drop 24 | ) 25 | self.SetDropTarget(dt) 26 | 27 | self.asset_handlers = { 28 | '.bam': self.add_model, 29 | '.dae': self.add_model, 30 | '.egg': self.add_model, 31 | '.gltf': self.add_model, 32 | '.jpg': self.add_texture, 33 | '.obj': self.add_model, 34 | '.png': self.add_texture, 35 | '.ptf': self.add_particles, 36 | '.pz': self.add_model, 37 | '.xml': self.add_prefab, # Conflicts with scene xml extn. 38 | } 39 | 40 | def screen_to_viewport(self, x, y): 41 | x = (x / float(self.GetSize()[0])- 0.5) * 2 42 | y = (y / float(self.GetSize()[1]) - 0.5) * -2 43 | return x, y 44 | 45 | def get_drop_component(self, x, y): 46 | x, y = self.screen_to_viewport(x, y) 47 | np = self.base.selection.get_node_path_at_position(x, y) 48 | return get_base().node_manager.wrap(np) 49 | 50 | def drag_drop_validate(self, x, y, data): 51 | """ 52 | Accept strings (assets from the resources panel) or components. 53 | 54 | """ 55 | are_assets = all([isinstance(elem, str) for elem in data]) 56 | are_comps = all([isinstance(elem, Base) for elem in data]) 57 | if not are_assets and not are_comps: 58 | return False 59 | 60 | if are_assets: 61 | return all([ 62 | os.path.splitext(elem)[1] in self.asset_handlers 63 | for elem in data 64 | ]) 65 | else: 66 | drop_comp = self.get_drop_component(x, y) 67 | return drop_comp.get_possible_connections(data) 68 | 69 | def on_drop(self, x, y, data): 70 | all_assets = all([isinstance(elem, str) for elem in data]) 71 | if all_assets: 72 | for file_path in data: 73 | ext = os.path.splitext(file_path)[1] 74 | handler = self.asset_handlers[ext] 75 | handler(file_path, x, y) 76 | else: 77 | menu = CustomMenu() 78 | drop_comp = self.get_drop_component(x, y) 79 | possible_conns = drop_comp.get_possible_connections(data) 80 | for conn_name, conn in possible_conns.items(): 81 | action = ActionItem( 82 | conn_name, 83 | '', 84 | self.on_connect, 85 | args=(data, drop_comp, conn_name) 86 | ) 87 | menu.AppendActionItem(action, menu) 88 | self.PopupMenu(menu) 89 | menu.Destroy() 90 | 91 | def on_connect(self, evt, args): 92 | drag_comps, drop_comp, conn_name = args 93 | cmds.set_attribute([drop_comp], conn_name, drag_comps[0]) 94 | 95 | def add_model(self, file_path, x, y): 96 | rel_path = get_base().project.get_project_relative_path(file_path) 97 | logging.info(f'Adding model: {rel_path}') 98 | self.base.add_component('ModelRoot', fullpath=rel_path) 99 | 100 | def add_particles(self, file_path, x, y): 101 | logging.info(f'Adding particle: {file_path}') 102 | self.base.add_component('ParticleEffect', config_path=file_path) 103 | 104 | def add_prefab(self, file_path, x, y): 105 | logging.info(f'Adding prefab: {file_path}') 106 | self.base.add_prefab(file_path) 107 | 108 | def add_texture(self, file_path, x, y): 109 | rel_path = get_base().project.get_project_relative_path(file_path) 110 | logging.info(f'Adding texture: {rel_path}') 111 | tex_comp = self.base.add_component('Texture', fullpath=rel_path) 112 | drop_comp = self.get_drop_component(x, y) 113 | 114 | # TODO: Need to wrap this with a command so it can be undone. 115 | drop_comp.texture = tex_comp 116 | -------------------------------------------------------------------------------- /src/pandaEditor/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import subprocess 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def indent(elem, level=0, indent_size=4): 10 | """ 11 | Function used to 'prettify' output xml from cElementTree's tree.getroot() 12 | method into lines so it's easily read. 13 | 14 | """ 15 | i = '\n' + level * (indent_size * ' ') 16 | if len(elem): 17 | if not elem.text or not elem.text.strip(): 18 | elem.text = i + indent_size * ' ' 19 | if not elem.tail or not elem.tail.strip(): 20 | elem.tail = i 21 | for elem in elem: 22 | indent(elem, level + 1, indent_size) 23 | if not elem.tail or not elem.tail.strip(): 24 | elem.tail = i 25 | else: 26 | if level and (not elem.tail or not elem.tail.strip()): 27 | elem.tail = i 28 | 29 | 30 | def popen_and_call(OnExit, printStdout, *popenArgs, **popenKWArgs): 31 | """ 32 | Runs a subprocess.Popen, and then calls the function onExit when the 33 | subprocess completes. 34 | 35 | Use it exactly the way you'd normally use subprocess.Popen, except include 36 | q callable to execute as the first argument. onExit is a callable object, 37 | and *popenArgs and **popenKWArgs are simply passed up to subprocess.Popen. 38 | 39 | """ 40 | def run_in_thread(OnExit, printStdout, popenArgs, popenKWArgs): 41 | proc = subprocess.Popen(*popenArgs, **popenKWArgs) 42 | proc.wait() 43 | if printStdout: 44 | logger.info(proc.stdout.read()) 45 | OnExit() 46 | return 47 | 48 | thread = threading.Thread( 49 | target=run_in_thread, 50 | args=( 51 | OnExit, 52 | printStdout, 53 | popenArgs, 54 | popenKWArgs 55 | ) 56 | ) 57 | thread.start() 58 | 59 | # Return immediately after the thread starts. 60 | return thread 61 | 62 | 63 | def camel_case_to_label(name): 64 | return ' '.join(word.title() for word in name.split('_')) -------------------------------------------------------------------------------- /src/plugins/example_editor/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pandaEditor.plugins import base 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ExampleEditorPlugin(base.Base): 10 | 11 | def on_init(self): 12 | # logger.info('on_init') 13 | pass 14 | 15 | def on_update(self, comps): 16 | # logger.info(f'on_update {comps}') 17 | pass 18 | 19 | def on_scene_close(self): 20 | # logger.info('on_scene_close') 21 | pass 22 | 23 | def on_project_modified(self, file_paths): 24 | # logger.info(f'on_project_modified {file_paths}') 25 | pass -------------------------------------------------------------------------------- /src/plugins/example_editor/plugin.yapsy-plugin: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = ExampleEditorPlugin 3 | Module = plugin 4 | -------------------------------------------------------------------------------- /src/plugins/example_game/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from game.plugins import base 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ExampleGamePlugin(base.Base): 10 | 11 | def on_init(self): 12 | # logger.info('on_init') 13 | pass -------------------------------------------------------------------------------- /src/plugins/example_game/plugin.yapsy-plugin: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = ExampleGamePlugin 3 | Module = plugin 4 | -------------------------------------------------------------------------------- /src/plugins/helpers/data/vertex_colours.sha: -------------------------------------------------------------------------------- 1 | //Cg 2 | 3 | 4 | void vshader( 5 | uniform float4x4 mat_modelproj, 6 | in float4 vtx_position : POSITION, 7 | in float4 vtx_color : COLOR, 8 | out float4 l_position : POSITION, 9 | out float4 l_color : COLOR) 10 | { 11 | l_position = mul(mat_modelproj, vtx_position); 12 | l_color = vtx_color; 13 | } 14 | 15 | 16 | void fshader( 17 | in float4 l_color : COLOR, 18 | out float4 o_color : COLOR) 19 | { 20 | o_color = l_color; 21 | } -------------------------------------------------------------------------------- /src/plugins/helpers/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import panda3d.core as pc 5 | from direct.showbase.PythonUtil import getBase as get_base 6 | from panda3d.core import Shader 7 | 8 | from pandaEditor.plugins import base 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class HelpersPlugin(base.Base): 15 | 16 | def on_init(self): 17 | 18 | # Load vertex colour shader. 19 | vtx_shader = Shader.load(self.get_model_path('vertex_colours.sha')) 20 | 21 | # Set editor meshes. 22 | model_to_wrapper = { 23 | 'camera.egg': 'BaseCam', 24 | 'ambient_light.egg': 'AmbientLight', 25 | 'spotlight.egg': 'Spotlight', 26 | 'point_light.egg': 'PointLight', 27 | 'directional_light.egg': 'DirectionalLight' 28 | } 29 | for model_name, wrpr_name in model_to_wrapper.items(): 30 | model = get_base().loader.load_model(self.get_model_path(model_name)) 31 | model.set_shader(vtx_shader) 32 | try: 33 | get_base().node_manager.wrappers[wrpr_name].set_editor_geometry(model) 34 | except KeyError: 35 | logger.error( 36 | f'Could not set geometry on wrapper: {wrpr_name}. Perhaps ' 37 | f'it\'s not loaded?' 38 | ) 39 | 40 | def get_model_path(self, file_name): 41 | """ 42 | Return the model path for the specified file name. Model paths are 43 | given as absolute paths so there is no need to alter the model search 44 | path - doing so may give weird results if there is a similarly named 45 | model in the user's project. 46 | 47 | """ 48 | dir_path = os.path.join(os.path.split(__file__)[0], 'data') 49 | model_path = pc.Filename.from_os_specific(os.path.join(dir_path, file_name)) 50 | return model_path 51 | -------------------------------------------------------------------------------- /src/plugins/helpers/plugin.yapsy-plugin: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = Helpers 3 | Module = plugin 4 | -------------------------------------------------------------------------------- /src/plugins/primitives/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import p3d 5 | import panda3d.core as pc 6 | import wx 7 | 8 | from direct.showbase.PythonUtil import getBase as get_base 9 | from pandaEditor.plugins import base 10 | from pandaEditor.ui.createdialog import CreateDialog 11 | from wxExtra import utils as wxutils 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | ID_CREATE_BOX = wx.NewId() 18 | ID_CREATE_CONE = wx.NewId() 19 | ID_CREATE_CYLINDER = wx.NewId() 20 | ID_CREATE_SPHERE = wx.NewId() 21 | 22 | 23 | class PrimitivesPlugin(base.Base): 24 | 25 | def on_init(self): 26 | 27 | # Build primitives menu. 28 | m_prim = wx.Menu() 29 | m_prim.Append(ID_CREATE_BOX, '&Box') 30 | m_prim.Append(ID_CREATE_CONE, '&Cone') 31 | m_prim.Append(ID_CREATE_CYLINDER, '&Cylinder') 32 | m_prim.Append(ID_CREATE_SPHERE, '&Sphere') 33 | 34 | # Bind primitives menu events. 35 | ui = get_base().frame 36 | wxutils.IdBind(ui, wx.EVT_MENU, ID_CREATE_BOX, self.on_create_box) 37 | wxutils.IdBind(ui, wx.EVT_MENU, ID_CREATE_CONE, self.on_create_cone) 38 | wxutils.IdBind(ui, wx.EVT_MENU, ID_CREATE_CYLINDER, self.on_create_cylinder) 39 | wxutils.IdBind(ui, wx.EVT_MENU, ID_CREATE_SPHERE, self.on_create_sphere) 40 | 41 | # Append to create menu 42 | ui.mCreate.AppendSeparator() 43 | ui.mCreate.AppendSubMenu(m_prim, '&Primitives') 44 | 45 | def create_geometry(self, name, geom): 46 | dir_path = get_base().project.models_directory 47 | asset_name = get_base().project.get_unique_asset_name( 48 | f'{name}.bam', 49 | dir_path 50 | ) 51 | asset_path = os.path.join(dir_path, asset_name) 52 | geom.write_bam_file(pc.Filename.from_os_specific(asset_path)) 53 | logger.info(f'Wrote bam file: {asset_path}') 54 | 55 | # Add the new model to the scene. 56 | # TODO: This is duplicated from viewport, so maybe do further 57 | # abstraction there. 58 | rel_path = get_base().project.get_project_relative_path(asset_path) 59 | logging.info(f'Adding model: {rel_path}') 60 | get_base().add_component('ModelRoot', fullpath=rel_path) 61 | 62 | def on_create(self, primitive, props, geom_fn): 63 | dialog = CreateDialog( 64 | f'Create {primitive} Primitive', 65 | props, 66 | wx.GetApp().GetTopWindow(), 67 | title='Create Primitive Model', 68 | ) 69 | dialog.CenterOnParent() 70 | if dialog.ShowModal() == wx.ID_OK: 71 | geom = geom_fn(**dialog.GetValues()) 72 | 73 | # Note: Seems like we have to wrap with model root here or else model 74 | # cannot be loaded using the editor. 75 | root = pc.NodePath(pc.ModelRoot('root')) 76 | root.attach_new_node(geom) 77 | self.create_geometry(primitive.lower(), root) 78 | 79 | def on_create_box(self, evt): 80 | props = { 81 | 'width': 1, 82 | 'height': 1, 83 | 'depth': 1, 84 | 'origin': pc.Point3(0, 0, 0), 85 | 'flip_normals': False, 86 | } 87 | self.on_create('box', props, p3d.geometry.box) 88 | 89 | def on_create_cone(self, evt): 90 | props = { 91 | 'radius': 1.0, 92 | 'height': 2.0, 93 | 'num_segs': 16, 94 | 'degrees': 360, 95 | 'axis': pc.Vec3(0, 0, 1), 96 | 'origin': pc.Point3(0, 0, 0), 97 | } 98 | self.on_create('cone', props, p3d.geometry.cone) 99 | 100 | def on_create_cylinder(self, evt): 101 | props = { 102 | 'radius': 1.0, 103 | 'height': 2.0, 104 | 'num_segs': 16, 105 | 'degrees': 360, 106 | 'axis': pc.Vec3(0, 0, 1), 107 | 'origin': pc.Point3(0, 0, 0), 108 | } 109 | self.on_create('cylinder', props, p3d.geometry.cylinder) 110 | 111 | def on_create_sphere(self, evt): 112 | props = { 113 | 'radius': 1.0, 114 | 'num_segs': 16, 115 | 'degrees': 360, 116 | 'axis': pc.Vec3(0, 0, 1), 117 | 'origin': pc.Point3(0, 0, 0), 118 | } 119 | self.on_create('sphere', props, p3d.geometry.sphere) 120 | -------------------------------------------------------------------------------- /src/plugins/primitives/plugin.yapsy-plugin: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = Primitives 3 | Module = plugin 4 | -------------------------------------------------------------------------------- /src/run_tests.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | 4 | from panda3d.core import ConfigVariableBool 5 | 6 | 7 | ConfigVariableBool('editor_mode', False).set_value(True) 8 | ConfigVariableBool('no_ui', False).set_value(True) 9 | 10 | 11 | logging.basicConfig( 12 | level=logging.INFO, 13 | format='%(asctime)s [%(levelname)s] %(message)s', 14 | ) 15 | 16 | 17 | if __name__ == '__main__': 18 | suite = unittest.TestLoader().discover('.', pattern = 'test_*.py') 19 | unittest.TextTestRunner(verbosity=2).run(suite) 20 | -------------------------------------------------------------------------------- /src/wxExtra/__init__.py: -------------------------------------------------------------------------------- 1 | from .dirTreeCtrl import DirTreeCtrl 2 | from .auiManagerConfig import AuiManagerConfig 3 | from .customMenu import CustomMenu 4 | from .customListCtrl import CustomListCtrl 5 | from .customTreeCtrl import CustomTreeCtrl 6 | from . import propertyGrid as wxpg 7 | from .actionItem import ActionItem 8 | from .customAuiToolBar import CustomAuiToolBar -------------------------------------------------------------------------------- /src/wxExtra/actionItem.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | 4 | class ActionItem: 5 | 6 | def __init__(self, text, iconPath, cmd, id=None, args=None, kwargs=None, 7 | helpStr='', kind=wx.ITEM_NORMAL): 8 | self._text = text 9 | self._iconPath = iconPath 10 | self._cmd = cmd 11 | self._id = id 12 | self._args = args or [] 13 | self._kwargs = kwargs or {} 14 | self._helpStr = helpStr 15 | self._kind = kind 16 | 17 | # Generate a unique id if one wasn't supplied 18 | if self._id is None: 19 | self._id = wx.NewId() 20 | 21 | def GetText(self): 22 | return self._text 23 | 24 | def GetIconPath(self): 25 | return self._iconPath 26 | 27 | def GetCommand(self): 28 | return self._cmd 29 | 30 | def GetId(self): 31 | return self._id 32 | 33 | def GetArguments(self): 34 | return self._args 35 | 36 | def GetKwarguments(self): 37 | return self._kwargs 38 | 39 | def GetHelpString(self): 40 | return self._helpStr 41 | 42 | def GetKind(self): 43 | return self._kind 44 | -------------------------------------------------------------------------------- /src/wxExtra/auiManagerConfig.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | 4 | class AuiManagerConfig(wx.Config): 5 | 6 | """ 7 | Custom wxConfig class to handle the main frame size and position, plus all 8 | the the panes of the aui. 9 | """ 10 | 11 | def __init__(self, auiMgr, *args, **kwargs): 12 | wx.Config.__init__(self, *args, **kwargs) 13 | 14 | self.auiMgr = auiMgr 15 | self.win = self.auiMgr.GetManagedWindow() 16 | 17 | # Key constants 18 | if self.win is not None: 19 | winName = self.win.GetName() 20 | self._keyWinPosX = winName + 'PosX' 21 | self._keyWinPosY = winName + 'PosY' 22 | self._keyWinSizeX = winName + 'SizeX' 23 | self._keyWinSizeY = winName + 'SizeY' 24 | self._keyWinMax = winName + 'Max' 25 | self._keyPerspDefault = 'perspDefault' 26 | 27 | def Save(self): 28 | """Save all panel layouts for the aui manager.""" 29 | # Get old window position and size. We'll use these instead of the 30 | # maximized window's size and position. 31 | winPosX = self.ReadInt(self._keyWinPosX) 32 | winPosY = self.ReadInt(self._keyWinPosY) 33 | winSizeX = self.ReadInt(self._keyWinSizeX) 34 | winSizeY = self.ReadInt(self._keyWinSizeY) 35 | 36 | self.DeleteAll() 37 | 38 | if self.win is not None: 39 | 40 | # Don't save maximized window properties 41 | if not self.win.IsMaximized(): 42 | winPosX, winPosY = self.win.GetPosition() 43 | winSizeX, winSizeY = self.win.GetSize() 44 | 45 | # Save the managed window position and size 46 | self.SavePosition(winPosX, winPosY) 47 | self.SaveSize(winSizeX, winSizeY) 48 | 49 | # Save the managed window state 50 | winMax = self.win.IsMaximized() 51 | self.WriteBool(self._keyWinMax, winMax) 52 | 53 | # Save the current perspective as the default 54 | self.Write(self._keyPerspDefault, self.auiMgr.SavePerspective()) 55 | 56 | def SavePosition(self, x, y): 57 | """Save the managed window's position.""" 58 | self.WriteInt(self._keyWinPosX, x) 59 | self.WriteInt(self._keyWinPosY, y) 60 | 61 | def SaveSize(self, x, y): 62 | """Save the managed window's size.""" 63 | self.WriteInt(self._keyWinSizeX, x) 64 | self.WriteInt(self._keyWinSizeY, y) 65 | 66 | def Load(self): 67 | """Load all panel layouts for the aui manager.""" 68 | if self.win is not None: 69 | 70 | # Load the managed window state 71 | winMax = self.ReadBool(self._keyWinMax) 72 | self.win.Maximize(winMax) 73 | 74 | # Load the managed window size 75 | winSizeX = self.ReadInt(self._keyWinSizeX) 76 | winSizeY = self.ReadInt(self._keyWinSizeY) 77 | if winSizeX and winSizeY: 78 | self.win.SetSize((winSizeX, winSizeY)) 79 | 80 | # Load the managed window position 81 | winPosX = self.ReadInt(self._keyWinPosX) 82 | winPosY = self.ReadInt(self._keyWinPosY) 83 | if winPosX and winPosY: 84 | self.win.SetPosition((winPosX, winPosY)) 85 | 86 | # Load the default perspective 87 | winPersp = self.Read(self._keyPerspDefault) 88 | self.auiMgr.LoadPerspective(winPersp) -------------------------------------------------------------------------------- /src/wxExtra/customAuiToolBar.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | import wxExtra 4 | from wxExtra import utils 5 | 6 | 7 | class CustomAuiToolBar(wx.aui.AuiToolBar): 8 | 9 | def __init__(self, *args, **kwargs): 10 | wx.aui.AuiToolBar.__init__(self, *args, **kwargs) 11 | self._bmpSize = None 12 | 13 | def AppendActionItem(self, actn): 14 | wx.Log.SetLogLevel(0) # Icon gives an sRGB error but still displays. This suppresses the error. 15 | actnIcon = wxExtra.utils.ImgToBmp(actn.GetIconPath(), self.GetToolBitmapSize()) 16 | self.AddTool(actn.GetId(), actn.GetText(), actnIcon, 17 | actn.GetHelpString(), actn.GetKind()) 18 | self.Bind(wx.EVT_TOOL, actn.GetCommand(), id=actn.GetId()) 19 | 20 | def AppendActionItems(self, actns): 21 | for actn in actns: 22 | self.AppendActionItem(actn) 23 | 24 | def GetToolBitmapSize(self): 25 | """ 26 | Workaround as GetToolBitmapSize seems only to return the default 27 | icon size. 28 | """ 29 | if self._bmpSize is None: 30 | return wx.aui.AuiToolBar.GetToolBitmapSize(self) 31 | 32 | return self._bmpSize 33 | 34 | def SetToolBitmapSize(self, size): 35 | """ 36 | Workaround as GetToolBitmapSize seems only to return the default 37 | icon size. 38 | """ 39 | wx.aui.AuiToolBar.SetToolBitmapSize(self, size) 40 | 41 | self._bmpSize = size 42 | 43 | def EnableAllTools(self, state): 44 | """Enable or disable all tools in the toolbar.""" 45 | for i in range(self.GetToolCount()): 46 | tool = self.FindToolByIndex(i) 47 | self.EnableTool(tool.GetId(), state) 48 | self.Refresh() 49 | -------------------------------------------------------------------------------- /src/wxExtra/customListCtrl.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.lib.mixins.listctrl as listmix 3 | 4 | 5 | class CustomListCtrl(wx.ListCtrl, listmix.ListCtrlAutoWidthMixin): 6 | 7 | def __init__(self, *args, **kwargs): 8 | wx.ListCtrl.__init__(self, *args, **kwargs) 9 | listmix.ListCtrlAutoWidthMixin.__init__(self) 10 | 11 | def GetSelections(self): 12 | """ 13 | Gets the selected items for the list control. 14 | Selection is returned as a list of selected indices, low to high. 15 | """ 16 | selections = [] 17 | 18 | # Start at -1 to get the first selected item 19 | current = -1 20 | while True: 21 | next = self.GetNextSelected(current) 22 | if next == -1: 23 | return selections 24 | 25 | selections.append(next) 26 | current = next 27 | 28 | return selections 29 | 30 | def GetAllItems(self): 31 | """Return a list of all items in the control.""" 32 | items = [] 33 | for i in range(self.GetItemCount()): 34 | items.append(self.GetItem(i)) 35 | 36 | return items 37 | 38 | def FindItems(self, start, strs, partial): 39 | """Return a list with all find results for each input string.""" 40 | items = [] 41 | for str in strs: 42 | items.append(self.FindItem(start, str, partial)) 43 | 44 | return items 45 | 46 | def FindItemByData(self, data): 47 | """ 48 | Return the index for the item in the list which owns the supplied 49 | data. 50 | """ 51 | for i in range(self.GetItemCount()): 52 | if self.GetItem(i).GetItemData() == data: 53 | return i 54 | 55 | # No match found, return -1 56 | return -1 -------------------------------------------------------------------------------- /src/wxExtra/customMenu.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from wxExtra import utils 4 | 5 | 6 | class CustomMenu(wx.Menu): 7 | 8 | """ 9 | Custom wxMenu class with convenience methods to add menu items with 10 | icons. 11 | """ 12 | 13 | def AppendActionItem(self, actn, parent): 14 | actnText = actn.GetText() 15 | if not actnText.startswith('&'): 16 | actnText = '&' + actnText 17 | 18 | mItem = wx.MenuItem(self, actn.GetId(), actnText) 19 | 20 | # Create the icon if iconPath is present 21 | iconPath = actn.GetIconPath() 22 | if False: # Disabled for now 23 | img = wx.Image(iconPath, wx.BITMAP_TYPE_ANY) 24 | img.Rescale(16, 16, quality=wx.IMAGE_QUALITY_HIGH) 25 | mItem.SetBitmap(img.ConvertToBitmap()) 26 | 27 | # Append check item or regular item 28 | if actn.GetKind() == wx.ITEM_CHECK: 29 | self.AppendCheckItem(actn.GetId(), actnText) 30 | else: 31 | self.Append(mItem) 32 | 33 | # Bind the menu event - use args if provided. 34 | args = actn.GetArguments() 35 | if args: 36 | utils.IdBind(parent, wx.EVT_MENU, actn.GetId(), actn.GetCommand(), args) 37 | else: 38 | parent.Bind(wx.EVT_MENU, actn.GetCommand(), id=actn.GetId()) 39 | 40 | def AppendActionItems(self, actns, parent): 41 | for actn in actns: 42 | self.AppendActionItem(actn, parent) 43 | 44 | def AppendIconItem(self, id, text, help, iconPath): 45 | 46 | # Create the icon and resize 47 | img = wx.Image(iconPath, wx.BITMAP_TYPE_ANY) 48 | img.Rescale(16, 16) 49 | 50 | # Create and append the new menu item 51 | mItem = wx.MenuItem(self, id, text, help) 52 | mItem.SetBitmap(img.ConvertToBitmap()) 53 | self.Append(mItem) 54 | 55 | def EnableAllTools(self, state): 56 | """Enable or disable all tools in the toolbar.""" 57 | for item in self.GetMenuItems(): 58 | item.Enable(state) 59 | 60 | def Clear(self): 61 | for item in self.GetMenuItems(): 62 | self.DeleteItem(item) -------------------------------------------------------------------------------- /src/wxExtra/customTreeCtrl.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.lib.agw.customtreectrl as ct 3 | 4 | 5 | class CustomTreeCtrl(ct.CustomTreeCtrl): 6 | 7 | def __init__(self, *args, **kwargs): 8 | ct.CustomTreeCtrl.__init__(self, *args, **kwargs) 9 | 10 | self.SetBorderPen(wx.Pen((0, 0, 0), 0, wx.TRANSPARENT)) 11 | self.EnableSelectionGradient(True) 12 | self.SetGradientStyle(True) 13 | self.SetFirstGradientColour(wx.Colour(46, 46, 46)) 14 | self.SetSecondGradientColour(wx.Colour(123, 123, 123)) 15 | 16 | def GetItemChildren(self, pItem, recursively=False): 17 | """ 18 | wxPython's standard tree control does not have a get item children 19 | method by default. 20 | """ 21 | cItems = [] 22 | 23 | cItem, cookie = self.GetFirstChild(pItem) 24 | while cItem is not None and cItem.IsOk(): 25 | cItems.append(cItem) 26 | if recursively: 27 | cItems.extend(self.GetItemChildren(cItem, True)) 28 | cItem = self.GetNextSibling(cItem) 29 | 30 | return cItems 31 | 32 | def FindItemByText(self, text): 33 | """ 34 | Iterate through all items and return the first which matches the given 35 | text. 36 | """ 37 | def Recurse(item, text): 38 | for child in self.GetItemChildren(item): 39 | if self.GetItemText(child) == text: 40 | return child 41 | 42 | Recurse(child, text) 43 | 44 | return Recurse(self.GetRootItem(), text) 45 | 46 | def GetAllItems(self): 47 | """Return a list of all items in the control.""" 48 | def GetChildren(item, allItems): 49 | if item is None: 50 | return 51 | for child in self.GetItemChildren(item): 52 | allItems.append(child) 53 | GetChildren(child, allItems) 54 | 55 | allItems = [] 56 | GetChildren(self.GetRootItem(), allItems) 57 | return allItems 58 | 59 | def SetItemParent(self, item, pItem, index=None, delete=True): 60 | """ 61 | Set the indicated item to the indicated parent item. Since the 62 | TreeCtrl has no native way to do this, we have to delete the item 63 | then recreate it and all its children from scratch. 64 | """ 65 | if index is None: 66 | index = self.GetChildrenCount(pItem) 67 | newItem = self.InsertItem(pItem, index, self.GetItemText(item)) 68 | 69 | # Rebuild all decendants. 70 | for cItem in self.GetItemChildren(item): 71 | self.SetItemParent(cItem, newItem, delete=False) 72 | 73 | # Set new items properties to match the old items properties. 74 | #self.SetItemData(newItem, self.GetItemData(item)) 75 | newItem.SetData(item.GetData()) 76 | if item.IsExpanded(): 77 | newItem.Expand() 78 | if item.IsSelected(): 79 | newItem.SetHilight(True) 80 | 81 | # As this is a tree we only have to delete the first item. Recursive 82 | # calls to this method don't need to call this. 83 | if delete: 84 | self.Delete(item) 85 | 86 | return newItem -------------------------------------------------------------------------------- /src/wxExtra/logpanel.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import wx 4 | 5 | 6 | class LogPanel(wx.Panel): 7 | 8 | """ 9 | Simple wxPanel containing a text control which will display the root 10 | logger's stream. 11 | 12 | """ 13 | 14 | class CustomConsoleHandler(logging.StreamHandler): 15 | 16 | def __init__(self, text_ctrl, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | 19 | self.text_ctrl = text_ctrl 20 | 21 | def emit(self, record): 22 | msg = self.format(record) 23 | self.text_ctrl.WriteText(msg + '\n') 24 | self.flush() 25 | 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(*args, **kwargs) 28 | 29 | # Build log text control. 30 | style = wx.TE_MULTILINE | wx.TE_RICH2 | wx.NO_BORDER | wx.TE_READONLY 31 | text_ctrl = wx.TextCtrl(self, style=style) 32 | 33 | # Set up another logging stream that prints to the text control. Use 34 | # the default handler's formatter. 35 | root_log = logging.getLogger() 36 | handler = self.CustomConsoleHandler(text_ctrl) 37 | handler.formatter = root_log.handlers[0].formatter 38 | root_log.addHandler(handler) 39 | 40 | # Build sizers. 41 | sizer = wx.BoxSizer(wx.VERTICAL) 42 | sizer.Add(text_ctrl, 1, wx.EXPAND) 43 | self.SetSizer(sizer) 44 | -------------------------------------------------------------------------------- /src/wxExtra/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import wx 4 | 5 | 6 | def file_dialog(message, wildcard, style, defaultDir=os.getcwd(), defaultFile=''): 7 | """ 8 | Generic file dialog method. If False is returned then the user has hit 9 | cancel or not selected a valid path. 10 | """ 11 | result = [] 12 | dlg = wx.FileDialog(wx.GetApp().GetTopWindow(), message, defaultDir, defaultFile, wildcard, style) 13 | if dlg.ShowModal() == wx.ID_OK: 14 | if style & wx.FD_MULTIPLE: 15 | result = dlg.GetPaths() 16 | else: 17 | result = [dlg.GetPath()] 18 | dlg.Destroy() 19 | return result 20 | 21 | 22 | def file_open_dialog(message, wildcard, style=0, defaultDir=os.getcwd(), defaultFile=''): 23 | """Generic file open dialog.""" 24 | style = style | wx.FD_OPEN | wx.FD_CHANGE_DIR 25 | file_paths = file_dialog(message, wildcard, style, defaultDir, defaultFile) 26 | if style & wx.FD_MULTIPLE: 27 | return file_paths 28 | return next(iter(file_paths), None) 29 | 30 | 31 | def file_save_dialog(message, wildcard, style=0, defaultDir=os.getcwd(), defaultFile=''): 32 | """Generic file save dialog.""" 33 | style = style | wx.FD_SAVE | wx.FD_CHANGE_DIR 34 | file_paths = file_dialog(message, wildcard, style, defaultDir, defaultFile) 35 | return next(iter(file_paths), None) 36 | 37 | 38 | def director_dialog(message, defaultPath=os.getcwd(), style=wx.DD_DEFAULT_STYLE): 39 | """Generic directory dialog.""" 40 | dlg = wx.DirDialog(wx.GetApp().GetTopWindow(), message, defaultPath, style) 41 | if dlg.ShowModal() == wx.ID_OK: 42 | result = dlg.GetPath() 43 | else: 44 | result = False 45 | dlg.Destroy() 46 | 47 | return result 48 | 49 | 50 | def message_dialog(message, caption, style): 51 | """Generic message dialog method.""" 52 | dlg = wx.MessageDialog(wx.GetApp().GetTopWindow(), message, caption, style) 53 | result = dlg.ShowModal() 54 | dlg.Destroy() 55 | 56 | return result 57 | 58 | 59 | def InformationDialog(message, caption='Information'): 60 | """Generic information dialog with ok button.""" 61 | return message_dialog(message, caption, wx.ICON_INFORMATION | wx.OK) 62 | 63 | 64 | def WarningDialog(message, caption='Warning'): 65 | """Generic warning dialog with ok button.""" 66 | return message_dialog(message, caption, wx.ICON_WARNING | wx.OK) 67 | 68 | 69 | def ErrorDialog(message, caption='Error'): 70 | """Generic error dialog with ok button.""" 71 | return message_dialog(message, caption, wx.ICON_ERROR | wx.OK) 72 | 73 | 74 | def YesNoDialog(message, caption, style=wx.ICON_QUESTION): 75 | """Generic message dialog with yes / no buttons.""" 76 | return message_dialog(message, caption, style | wx.YES_NO) 77 | 78 | 79 | def YesNoCancelDialog(message, caption, style=wx.ICON_QUESTION): 80 | """Generic message dialog with yes / no / cancel buttons.""" 81 | return message_dialog(message, caption, style | wx.YES_NO | wx.CANCEL) 82 | 83 | 84 | def ImgToBmp(filePath, size): 85 | """Return a wx bitmap from a filepath, scaled to the toolbar size.""" 86 | img = wx.Image(filePath) 87 | img.Rescale(size[0], size[1]) 88 | bmp = wx.Bitmap(img) 89 | return bmp 90 | 91 | 92 | def IdBind(self, event, id, handler, *args, **kwargs): 93 | self.Bind(event, lambda event: handler(event, *args, **kwargs), id=id) 94 | 95 | 96 | def get_clicked_item(ctrl, evt): 97 | """Return the id for the item involved in the mouse event.""" 98 | return ctrl.HitTest(wx.Point(evt.GetX(), evt.GetY()))[0] 99 | --------------------------------------------------------------------------------