/***************************************************************************** * J3D.org Copyright (c) 2000 * Java Source * * This source is licensed under the GNU LGPL v2.1 * Please read http://www.gnu.org/copyleft/lgpl.html for more information * * This software comes with the standard NO WARRANTY disclaimer for any * purpose. Use it at your own risk. If there's a problem you get to fix it. * ****************************************************************************/ package org.j3d.ui.navigation; // Standard imports import javax.media.j3d.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseEvent; import java.util.Enumeration; import javax.swing.Timer; import javax.vecmath.Point2d; import javax.vecmath.Point3d; import javax.vecmath.Vector3d; // Application specific imports import org.j3d.geom.IntersectionUtils; /** * A listener and handler responsible for executing all navigation commands * from mice and keyboards to move a viewpoint around a scene. *

* * This class does not contain any direct event handling. Instead it assumes * that another class with either derive from it or delegate to it to do the * actual movement processing. This allows it to be used as a standard AWT * event listener or a Java3D behaviour as required by the software. *

* * The class works like a standard VRML browser type navigation system. Press * the mouse button and drag to get the viewpoint moving. The more you drag * the button away from the start point, the faster the movement. The handler * does not recognize the use of the Shift key as a modifier to produce an * accelarated movement. *

* * This class will not change the cursor on the canvas in response to the * current mouse and navigation state. It will only notify the state change * listener. It is the responsibility of the listener to do this work. *

* * Separate states are allowed to be set for each button. Once one button is * pressed, all the other button presses are ignored. By default, all the * buttons start with no state set. The user will have to explicitly set * the state for each button to get them to work. *

* * The handler does not currently implement the Walk mode as it requires * picking handling for gravity and collision detection. *

* * Terrain Following *

* * When doing terrain following, the handler will project a ray from the * current viewpoint position to the ground position. It will then offset the * current position by the new position that we should be going to. If the * distance in the overall Y axis is less than the step height, the translation * will be allowed to proceed and the height adjusted to the new value. If it * is greater, then it will set the Z component to zero, allowing no forward * movement. Thus, if the next translation also has a sideways component, it * will simply shift sideways and not move forward. *

* * If there is no terrain under the current eye position, or the next eye * position, it will not change the Y axis value at all. *

* * If you do not wish to have terrain following for all modes, then pass a * null value for the terrain parameter to setWorldInfo(). *

* * * Collision Detection *

* * Collision detection is based on using a fixed point representation of the * avatar - we do not have a volumetric body for it. A ray is cast in the * direction that we are looking that is the length of the avatarSize plus the * amount that we are due to move this next frame. *

* * If you do not wish to have collision detection for all modes, then pass a * null value for the collidables parameter to setWorldInfo(). *

* * Navigation Modes *

* * NONE: All navigation is disabled. We ignore any requests from mouse or * keyboard to move the viewpoint. *

* * EXAMINE: The viewpoint is moved around the local origin provided by the * View transform group. The view will then rotate around that object looking * at the origin all the time. Note that if the local transform does not have * any transformation information set, this will result in undefined behaviour * (probably exceptions internally). There is no collision detection or terrain * following in this mode. *

* * FLY: The user moves through the scene that moves the eyepoint in forward, * reverse and side to side movements. There is collision detection, but no * terrain following. *

* * WALK: The user moves through the scene with left/right options and forward * reverse, but they are bound to the terrain and have collision detection. *

* * PAN: The camera moves in a sliding fashion along the given axis - the local * X or Z axis. There is not collision detection or terrain following. *

* * TILT: The camera rotates around the local axis in an up/down, left/right * fashion. It stays anchored in the one place and does not have terrain * following or collision detection. *

* * * TODO *

* The collision vector does not move according to the direction that we are * travelling rather than the direction we are facing. Allows us to walk * backwards through objects when we shouldn't. *

* Implement Examine mode handling * * @author per-frame movement algorithms by * Halden VR Centre, Institute for Energy Technology
* Terrain/Collision implementation by Justin Couch * @version $Revision $ */ public class NavigationHandler implements ActionListener { /** The default height of the avatar */ private static final float DEFAULT_AVATAR_HEIGHT = 1.8f; /*** The default size of the avatar for collisions */ private static final float DEFAULT_AVATAR_SIZE = 0.25f; /** The default step height of the avatar to climb */ private static final float DEFAULT_STEP_HEIGHT = 0.4f; /** Fixed vector always pointing down -Y */ private static final Vector3d Y_DOWN = new Vector3d(0, -1, 0); /** Fixed vector always pointing along -Z */ private static final Vector3d COLLISION_DIRECTION = new Vector3d(0, 0, -1); /** Intersection utilities used for terrain following */ private IntersectionUtils terrainIntersect; /** Intersection utilities used for terrain following */ private IntersectionUtils collideIntersect; /** Timer used to control smooth motion of the mouse */ private Timer timer; /** The view that we are moving about. */ private View view; /** The transform group above the view that is being moved each frame */ private TransformGroup viewTg = new TransformGroup(); /** The transform that belongs to the view transform group */ private Transform3D viewTx = new Transform3D(); /** An observer for information about updates for this transition */ private FrameUpdateListener updateListener; /** An observer for navigation state change information */ private NavigationStateListener navigationListener; /** An observer for collision information */ private CollisionListener collisionListener; /** * The current navigation state either set from us or externally as * the mouse if being dragged around. This is different to the state * that a given mouse button will generate */ private int navigationState; /** The previous state so we can set it back to normal */ private int previousState; /** The navigation state for use with button 1 */ private int buttonOneState; /** The navigation state for use with button 2 */ private int buttonTwoState; /** The navigation state for use with button 3 */ private int buttonThreeState; /** * Flag indicating that we are currently doing something and should * ignore any current mouse presses. */ private boolean movementInProgress; /** The mouse button that is currently being pressed */ private int activeButton; /** The current movement speed in m/s in the local coordinate system */ private float speed; // Java3D stuff for terrain following and collision detection /** The branchgroup to do the terrain picking on */ private BranchGroup terrain; /** The branchgroup to do collision picking on */ private BranchGroup collidables; /** Pick shape for terrain following */ private PickRay terrainPicker; /** Pick shape for collision detection */ private PickSegment collisionPicker; /** The local down direction for the terrain picking */ private Vector3d downVector; /** The vector along which we do collision detection */ private Vector3d collisionVector; /** The height of the avatar above the terrain */ private float avatarHeight; /** The size of the avatar for collision detection */ private float avatarSize; /** The step height of the avatar to allow stair climbing */ private float avatarStep; /** Difference between the avatar height and the step height */ private float lastTerrainHeight; /** Vector used to read the location value from a Transform3D */ private Vector3d locationVector; /** Point3D used to represent the location for the picker setup */ private Point3d locationPoint; /** Point 3D use to calculate the end point for collisions per frame */ private Point3d locationEndPoint; /** A point that we use for working calculations (coord transforms) */ private Point3d wkPoint; /** * Vector for doing difference calculations on the point we have and the next * while doing terrian following. */ private Vector3d diffVec; /** The intersection point that we really collided with */ private Point3d intersectionPoint; // The variables from here down are working variables during the drag // process. We declare them as class scope so that we don't generate // garbage for every mouse movement. The idea is we just re-use these // rather than create and destroy each time. /** The translation amount set by the last change in drag value */ private Vector3d dragTranslationAmt; /** A working value for the current frame's translation of the eye */ private Vector3d oneFrameTranslation; /** A working value for the current frame's rotation of the eye */ private Transform3D oneFrameRotation; /** The current translation total from the start of the movement */ private Vector3d viewTranslation; /** The current viewpoint location in world coordinates */ private Transform3D worldEyeTransform; /** The amount to move the view in mouse coords up/down */ private double mouseRotationY; /** The amount to move the view in mouse coords left/right */ private double mouseRotationX; /** The position where the mouse started it's last press */ private Point2d startMousePos; /** The latest position of the mouse from the last event */ private Point2d latestMousePos; /** * The difference between the last mouse point from the last event and * where it started. */ private Point2d mouseDifference; /** Flag to indicate that we should be doing collisions this time */ private boolean allowCollisions; /** Flag to indicate that we should do terrain following this time */ private boolean allowTerrainFollowing; /** * Create a new mouse handler with no view information set. This * handler will not do anything until the view transform * references have been set and the navigation modes for at least one * mouse button. */ public NavigationHandler() { navigationState = NavigationState.NO_STATE; previousState = NavigationState.NO_STATE; buttonOneState = NavigationState.NO_STATE; buttonTwoState = NavigationState.NO_STATE; buttonThreeState = NavigationState.NO_STATE; movementInProgress = false; // Timer: timer = new Timer(1000, this); timer.setInitialDelay(0); timer.setRepeats(true); timer.stop(); timer.setLogTimers(false); timer.setCoalesce(false); terrainIntersect = new IntersectionUtils(); collideIntersect = new IntersectionUtils(); worldEyeTransform = new Transform3D(); downVector = new Vector3d(); terrainPicker = new PickRay(); collisionVector = new Vector3d(); collisionPicker = new PickSegment(); intersectionPoint = new Point3d(); wkPoint = new Point3d(); diffVec = new Vector3d(); locationVector = new Vector3d(); locationPoint = new Point3d(); locationEndPoint = new Point3d(); dragTranslationAmt = new Vector3d(); oneFrameTranslation = new Vector3d(); oneFrameRotation = new Transform3D(); viewTranslation = new Vector3d(); mouseRotationY = 0; mouseRotationX = 0; startMousePos = new Point2d(); latestMousePos = new Point2d(); mouseDifference = new Point2d(); allowCollisions = false; allowTerrainFollowing = false; avatarHeight = DEFAULT_AVATAR_HEIGHT; avatarSize = DEFAULT_AVATAR_SIZE; avatarStep = DEFAULT_STEP_HEIGHT; lastTerrainHeight = 0; speed = 0; } /** * Set the view and it's related transform group to use. The transform * group must allow for reading the local to Vworld coordinates so that * we can accurately implement terrain following. * * @param view is the View object that we're modifying. * @param tg The transform group above the view object that should be used * @throws IllegalArgumentException One of the values is null and the * other is not * @throws IllegalStateException The transform group does not allow * reading of the vworld transforms or does not allow it to be set */ public void setViewInfo(View view, TransformGroup tg) { if(((view != null) && (tg == null)) || ((view == null) && (tg != null))) throw new IllegalArgumentException("View or TG is null when " + "the other isn't"); this.view = view; this.viewTg = tg; if(tg == null) return; if(tg.isLive()) { if(!viewTg.getCapability(Node.ALLOW_LOCAL_TO_VWORLD_READ)) throw new IllegalStateException("Live scenegraph and cannot " + "read the VWorld transform"); } else { tg.setCapability(Node.ALLOW_LOCAL_TO_VWORLD_READ); } // TODO: // Adjust the step height to the values from the scaled down vector // component so that it relates to the world coordinate system. } /** * Set the branchgroups to use for terrain and collision information. The * two are treated separately for the different processes. The caller may * choose to make them the same reference, but the code internally treats * them separately. *

* * Note For picking purposes, the code currently assumes that both * groups do not have any parent transforms. That is, their world origin * is the same as the transform group presented in the view information. * * @param terrainGroup The geometry to use as terrain for following * @param worldGroup The geometry to use for collisions */ public void setWorldInfo(BranchGroup terrainGroup, BranchGroup worldGroup) { terrain = terrainGroup; collidables = worldGroup; } /** * Set the information about the avatar that is used for collisions and * navigation information. * * @param height The heigth of the avatar above the terrain * @param size The distance between the avatar and collidable objects * @param stepHeight The height that an avatar can step over */ public void setAvatarInfo(float height, float size, float stepHeight) { avatarHeight = height; avatarSize = size; avatarStep = stepHeight; // TODO: // Adjust the step height to the values from the scaled down vector // component so that it relates to the world coordinate system. } /** * Set the navigation speed to the new value. The speed must be a * non-negative number. * * @param newSpeed The new speed value to use * @throws IllegalArgumentException The value was negative */ public void setNavigationSpeed(float newSpeed) { if(newSpeed < 0) throw new IllegalArgumentException("Negative speed value"); speed = newSpeed; } /** * Set the ability to use a given state within the handler for a * specific mouse button (up to 3). This allows the caller to control * exactly what states are allowed to be used and with which buttons. * Note that it is quite legal to set all three buttons to the same * navigation state * * @param button The mouse button value from * {@link java.awt.event.MouseEvent} * @param state The navigation state to use for that button */ public void setButtonNavigation(int button, int state) { switch(button) { case MouseEvent.BUTTON1_MASK: buttonOneState = state; break; case MouseEvent.BUTTON2_MASK: buttonTwoState = state; break; case MouseEvent.BUTTON3_MASK: buttonThreeState = state; break; } } /** * Set the listener for frame update notifications. By setting a value of * null it will clear the currently set instance * * @param l The listener to use for this transition */ public void setFrameUpdateListener(FrameUpdateListener l) { updateListener = l; } /** * Set the listener for navigation state change notifications. By setting * a value of null it will clear the currently set instance * * @param l The listener to use for change updates */ public void setNavigationStateListener(NavigationStateListener l) { navigationListener = l; } /** * Set the listener for collision notifications. By setting * a value of null it will clear the currently set instance * * @param l The listener to use for updates */ public void setCollisionListener(CollisionListener l) { collisionListener = l; } /** * Callback to ask the listener what navigation state it thinks it is * in. * * @return The state that the listener thinks it is in */ public int getNavigationState() { return navigationState; } //---------------------------------------------------------- // Methods required by the MouseMotionListener //---------------------------------------------------------- /** * Process a mouse drag event to change the current movement value from * the previously set value to the new value * * @param evt The event that caused this method to be called */ public void mouseDragged(MouseEvent evt) { if(viewTg == null) return; latestMousePos.set(evt.getX(), evt.getY()); mouseDifference.sub(startMousePos, latestMousePos); switch(navigationState) { case NavigationState.FLY_STATE: // Translate on Z: dragTranslationAmt.set(0,0,-mouseDifference.y * speed); // Rotate around Y: mouseRotationY = mouseDifference.x; allowCollisions = (collidables != null); allowTerrainFollowing = false; break; case NavigationState.PAN_STATE: // Translate on X,Y: dragTranslationAmt.set(-mouseDifference.x * 2, mouseDifference.y * 2, 0); allowCollisions = false; allowTerrainFollowing = false; break; case NavigationState.TILT_STATE: // Rotate arround X,Y: mouseRotationX = mouseDifference.y; mouseRotationY = mouseDifference.x; allowCollisions = false; allowTerrainFollowing = false; break; case NavigationState.WALK_STATE: // Translate on Z only dragTranslationAmt.set(0,0,-mouseDifference.y * speed); // Rotate around Y: mouseRotationY = mouseDifference.x; // do nothing allowCollisions = (collidables != null); allowTerrainFollowing = (terrain != null); break; case NavigationState.EXAMINE_STATE: // do nothing allowCollisions = false; allowTerrainFollowing = false; break; case NavigationState.NO_STATE: // do nothing allowCollisions = false; allowTerrainFollowing = false; break; } } /** * Process a mouse press and set the timer going. This will cause the * navigation state to be set depending on the mouse button pressed. * * @param evt The event that caused this method to be called */ public void mousePressed(MouseEvent evt) { if(movementInProgress || (viewTg == null)) return; int button = evt.getModifiers(); previousState = navigationState; activeButton = (button & (int)MouseEvent.MOUSE_EVENT_MASK); // Set the cursor: if((button & MouseEvent.BUTTON1_MASK) != 0) navigationState = buttonOneState; else if((button & MouseEvent.BUTTON2_MASK) != 0) navigationState = buttonTwoState; else if((button & MouseEvent.BUTTON3_MASK) != 0) navigationState = buttonThreeState; if(navigationListener != null) navigationListener.setNavigationState(navigationState); if(navigationState == NavigationState.NO_STATE) return; viewTg.getTransform(viewTx); viewTx.get(viewTranslation); startMousePos.set(evt.getX(), evt.getY()); timer.start(); if(navigationState == NavigationState.WALK_STATE) setInitialTerrainHeight(); } /** * Process a mouse release to return all the values back to normal. This * places all of the transforms back to identity and sets it as though the * nothing had happened. * * @param evt The event that caused this method to be called */ public void mouseReleased(MouseEvent evt) { int button = evt.getModifiers(); // Ignore this if the released button is not the one doing the // work. if((viewTg == null) || (movementInProgress && ((button & MouseEvent.MOUSE_EVENT_MASK) != activeButton))) return; movementInProgress = false; allowCollisions = false; allowTerrainFollowing = false; // There's a potential problem here of dealing with the user // clicking button a, then also clicking button b, then releasing // a followed by b. Both of the release events will trigger this a // set the view transform, which we don't really want to do. timer.stop(); viewTx.normalize(); mouseRotationY = 0; mouseRotationX = 0; oneFrameRotation.setIdentity(); oneFrameRotation.setIdentity(); dragTranslationAmt.scale(0); viewTg.getTransform(viewTx); navigationState = previousState; if(navigationListener != null) navigationListener.setNavigationState(previousState); } //---------------------------------------------------------- // Methods required by the ActionListener //---------------------------------------------------------- /** * Process an action event from the timer. This event is only for the time * and should not be associated with any other sort of action event like * menu callbacks. * * @param evt The event that caused this action to be called */ public void actionPerformed(ActionEvent actionEvent) { // Some magic numbers here that I don't know where they came from. // See the j3d.org implementation docs for more details from Lazlo. int frameDelay = 10 + (int)(view.getLastFrameDuration() / 2.0); double motionDelay = 0.000005 * frameDelay; timer.setDelay(frameDelay); // Firstly calculate where they should be in the next frame. Rotations // are fine to pass through as they never cause a collision. The // transforms we have to worry about so don't apply those until we've // checked for a collision. // RotateX: oneFrameRotation.rotX(mouseRotationX * motionDelay); viewTx.mul(oneFrameRotation); // RotateY: oneFrameRotation.rotY(mouseRotationY * motionDelay); viewTx.mul(oneFrameRotation); // Translation: oneFrameTranslation.set(dragTranslationAmt); oneFrameTranslation.scale(motionDelay); viewTx.transform(oneFrameTranslation); boolean collision = false; // If we allow collisions, adjust it for the collision amount if(allowCollisions) collision = !checkCollisions(); if(allowTerrainFollowing && !collision) collision = !checkTerrainFollowing(); if(collision) { if(collisionListener != null) collisionListener.avatarCollision(); oneFrameTranslation.z = 0; } // Now set the translation amounts that have been adjusted by any // collisions. viewTranslation.add(oneFrameTranslation); viewTx.setTranslation(viewTranslation); try { viewTg.setTransform(viewTx); } catch(Exception e) { //check for bad transform: System.out.println("Transformgroup set invalid"); viewTx.rotX(0); viewTg.setTransform(viewTx); // e.printStackTrace(); } if(updateListener != null) updateListener.viewerPositionUpdated(viewTx); } /** * Check the terrain following component of the translation for the next * frame. Adjusts the oneFrameTranslation amount depending on the terrain * and step height we encounter at this next location. * * @return true if the terrain following has successfully been applied * false means a collision. */ private boolean checkTerrainFollowing() { boolean ret_val = true; viewTg.getLocalToVworld(worldEyeTransform); worldEyeTransform.mul(viewTx); worldEyeTransform.get(locationVector); worldEyeTransform.transform(Y_DOWN, downVector); locationPoint.add(locationVector, oneFrameTranslation); terrainPicker.set(locationPoint, downVector); SceneGraphPath[] ground = terrain.pickAllSorted(terrainPicker); // if there is no ground below us, do nothing. if((ground == null) || (ground.length == 0)) { return ret_val; } double shortest_length = -1; for(int i = 0; i < ground.length; i++) { Transform3D local_tx = ground[i].getTransform(); local_tx.get(locationVector); Shape3D i_shape = (Shape3D)ground[i].getObject(); Enumeration geom_list = i_shape.getAllGeometries(); while(geom_list.hasMoreElements()) { GeometryArray geom = (GeometryArray)geom_list.nextElement(); if(geom == null) continue; if(terrainIntersect.rayUnknownGeometry(locationPoint, downVector, 0, geom, local_tx, wkPoint, false)) { diffVec.sub(locationPoint, wkPoint); if((shortest_length == -1) || (diffVec.lengthSquared() < shortest_length)) { shortest_length = diffVec.lengthSquared(); intersectionPoint.set(wkPoint); } } } } // No intersection!!!! How did that happen? Well, just exit and // pretend there was nothing below us if(shortest_length == -1) return true; // Is the difference in world Y values greater than the step height? // If so, then jump the viewpoint to the new terrain height plus the // avatar height above ground. Handles both rising and descending // terrain. If the difference is greater than the step height, we set // the translation to nothing in the Z direction. double terrain_step = intersectionPoint.y - lastTerrainHeight; double height_above_terrain = locationPoint.y - intersectionPoint.y; // Do we need to adjust the height? If so the check if the height is a // step that is too high or not if(height_above_terrain != avatarHeight) { if(terrain_step == 0) { // Flat surface. Check to see the avatar height is correct oneFrameTranslation.y = avatarHeight - height_above_terrain; } else if(terrain_step < avatarStep) { oneFrameTranslation.y += terrain_step; ret_val = true; } else { // prevent it. Set the transform to 0. ret_val = false; } } lastTerrainHeight = (float)intersectionPoint.y; return ret_val; } /** * Check the collision detection component of the translation for the next * frame. Basically test for a collision within the given distance that * would be travelled next frame. If nothing is picked then no collision * will occur. If it does find something then obviously a collision will * occurr so you do return a flag to say so. * * @param prefetch True if viewpoint info has already been fetched for * this frame * @return true if the no collisions detected, false means a collision. */ private boolean checkCollisions() { boolean ret_val = true; viewTg.getLocalToVworld(worldEyeTransform); worldEyeTransform.mul(viewTx); // Where are we now? worldEyeTransform.get(locationVector); locationPoint.set(locationVector); // Where are we going to be soon? worldEyeTransform.transform(COLLISION_DIRECTION, collisionVector); collisionVector.scale(avatarSize); locationEndPoint.add(locationVector, collisionVector); locationEndPoint.add(oneFrameTranslation); // We need to transform the end point to the direction that we are // currently travelling. At the moment, this always points forward // in the same direction as the viewpoint. collisionPicker.set(locationPoint, locationEndPoint); SceneGraphPath[] closest = collidables.pickAllSorted(collisionPicker); if((closest == null) || (closest.length == 0)) return true; boolean real_collision = false; float length = (float)collisionVector.length(); for(int i = 0; (i < closest.length) && !real_collision; i++) { // OK, so we collided on the bounds, lets check on the geometry // directly to see if we had a real collision. Java3D just gives // us the collision based on the bounding box intersection. We // might actually have just walked through something like an // archway. Transform3D local_tx = closest[i].getTransform(); Shape3D i_shape = (Shape3D)closest[i].getObject(); Enumeration geom_list = i_shape.getAllGeometries(); while(geom_list.hasMoreElements() && !real_collision) { GeometryArray geom = (GeometryArray)geom_list.nextElement(); if(geom == null) continue; real_collision = collideIntersect.rayUnknownGeometry(locationPoint, collisionVector, length, geom, local_tx, wkPoint, true); } ret_val = !real_collision; } return ret_val; } /** * Check the terrain height at the current position. This is done when * we first start moving a viewpoint with a mouse press. * * @return true if the terrain following has successfully been applied * false means a collision. */ private void setInitialTerrainHeight() { if(terrain == null) return; viewTg.getLocalToVworld(worldEyeTransform); worldEyeTransform.mul(viewTx); worldEyeTransform.get(locationVector); worldEyeTransform.transform(Y_DOWN, downVector); locationPoint.set(locationVector); terrainPicker.set(locationPoint, downVector); SceneGraphPath[] ground = terrain.pickAllSorted(terrainPicker); // if there is no ground below us, do nothing. if(ground == null) return; double shortest_length = -1; for(int i = 0; i < ground.length; i++) { Transform3D local_tx = ground[i].getTransform(); local_tx.get(locationVector); Shape3D i_shape = (Shape3D)ground[i].getObject(); Enumeration geom_list = i_shape.getAllGeometries(); while(geom_list.hasMoreElements()) { GeometryArray geom = (GeometryArray)geom_list.nextElement(); if(geom == null) continue; if(terrainIntersect.rayUnknownGeometry(locationPoint, downVector, 0, geom, local_tx, wkPoint, false)) { diffVec.sub(locationPoint, wkPoint); if((shortest_length == -1) || (diffVec.length() < shortest_length)) { shortest_length = diffVec.length(); intersectionPoint.set(wkPoint); } } } } // No intersection!!!! How did that happen? Well, just exit and // pretend there was nothing below us if(shortest_length != -1) lastTerrainHeight = (float)intersectionPoint.y; } }