Scaling collision shapes

Hi @Ali_RS,
your video is very interesting, fantastic tool!
I have never tried to resize collision shapes at runtime. With BoxCollisionShape and SphereCollisionShape everything works fine; I used the setScale() method.
Could you please explain to me how do you resize the height and radius of the CapsuleCollisionShape independently? According to the API, the scaling values ​​should all be the same.

  • Could you show me a code snippet with the formula you used?
  • Do you mind if I ask you also that of the CylinderCollisionShape?

Thanks in advance

1 Like

Hi @capdevon :grinning:

Thank you so much :slightly_smiling_face:

I do not scale it, but create a new shape on resizing.

Here is the source code for the CollisionShapeEditor class in case you want to take a look. Please feel free to ask if you have a question.

/**
 * @author Ali-RS
 */
public class CollisionShapeEditor extends BaseAppState {
    static Logger log = LoggerFactory.getLogger(CollisionShapeEditor.class);

    private static final String TITLE = "Collision Shapes";
    

    private final Map<Spatial, DebugShape> shapeMap = new HashMap<>();
    private final Node modelRoot = new Node("Model Root");
    private final Node debugNode = new Node("Debug Shapes");
    private final ShapeRemoveListener shapeRemoveListener = new ShapeRemoveListener();

    private final List<String> shapeNames = new ArrayList<>();

    private Material debugMat;

    // Define GUI stuff
    private Window window;
    private RollupPanel widget;
    private CheckboxModel compoundShape;

    public CollisionShapeEditor() {
        super(TITLE);
        setEnabled(false);
    }

    public void togglePanel() {
        setEnabled(!isEnabled());
    }

    @Override
    protected void initialize(Application app) {
        URL resource = getClass().getResource("/CollisionShapes");
        File file = new File(decodeUrl(resource));
        if(file.exists()) {
            for (File f : file.listFiles()) {
                if( f.getName().matches(".*[-]\\d+[.]sav$") ) {
                    shapeNames.add(f.getName().replace(".sav", ""));
                }
            }
        }

        // Initialize GUI
        window = createWindow(TITLE);
        getState(MainMenuState.class).registerItem(TITLE, this::togglePanel);

        debugMat = GuiGlobals.getInstance().createMaterial(ColorRGBA.Yellow, false).getMaterial();
        debugMat.getAdditionalRenderState().setWireframe(true);
    }

    @Override
    protected void cleanup(Application app) {
        getState(MainMenuState.class).removeItem(TITLE);
        shapeNames.clear();
    }

    @Override
    protected void onEnable() {
        getState(WindowManagerState.class).add(window);

        Node rootNode = ApplicationGlobals.getInstance().getRootNode();
        rootNode.attachChild(modelRoot);
        rootNode.attachChild(debugNode);
        getState(BlenderCameraState.class).setEnabled(true);

        SelectionAppState selectionState = getState(SelectionAppState.class);
        selectionState.registerCollisionRoot(debugNode, collisionResult -> {
            Spatial hit = collisionResult.getGeometry();
            while ((hit = hit.getParent()) != null) {
                if(compoundShape.isChecked() && shapeMap.containsKey(hit)) {
                    Container container = (Container) widget.getContents().getChild("Properties");
                    container.clearChildren();
                    container.addChild(shapeMap.get(hit).getProperties());
                    return hit;
                }
            }
            return null;
        });
        selectionState.getSelectionModel().addPropertyChangeListener(shapeRemoveListener);
    }

    @Override
    protected void onDisable() {
        window.remove();
        getState(BlenderCameraState.class).setEnabled(false);

        SelectionAppState selectionState = getState(SelectionAppState.class);
        selectionState.removeCollisionRoot(debugNode);
        selectionState.getSelectionModel().removePropertyChangeListener(shapeRemoveListener);

        modelRoot.removeFromParent();
        debugNode.removeFromParent();

        if(widget != null) closeWidget();
    }

    protected CollisionShape loadShape(String shapeName) {
        CollisionShape shape = null;

        URL resource = getClass().getResource("/CollisionShapes/" + shapeName.concat(".sav"));
        File f = new File(decodeUrl(resource));
        try {
            if (!f.exists()) {
                log.error("File not exist:" + f);
                return null;
            }

            BinaryImporter imp = BinaryImporter.getInstance();
            imp.setAssetManager(getApplication().getAssetManager());

            shape = (CollisionShape) imp.load(f);
        } catch (IOException ex) {
            log.error("Error loading collision shape!", ex);
        }
        return shape;
    }

    public void saveShape(CollisionShape shape, String shapeName) {
        BinaryExporter ex = BinaryExporter.getInstance();
        try {
            URL resource = getClass().getResource("/CollisionShapes");
            File f = new File(decodeUrl(resource), shapeName.concat(".sav"));
            if (!f.exists() && !f.createNewFile()) {
                throw new IllegalStateException("File cannot be created:" + f);
            }

            ex.save(shape, f);
            log.info("Exporting collision shape to: {}", f);
        } catch (IOException e) {
            log.error("Error exporting collision shape!", e);
        }
    }

    private DebugShape createDebugShape(CollisionShape shape) {
        if(shape instanceof BoxCollisionShape) {
            BoxCollisionShape box = (BoxCollisionShape) shape;
            return new BoxDebugShape(box.getHalfExtents(null).multLocal(2));
        } else if (shape instanceof SphereCollisionShape) {
            SphereCollisionShape sphere = (SphereCollisionShape) shape;
            return new SphereDebugShape(sphere.getRadius());
        } else if (shape instanceof CylinderCollisionShape) {
            CylinderCollisionShape cylinder = (CylinderCollisionShape) shape;
            return new CylinderDebugShape(cylinder.maxRadius(), cylinder.getHeight(), cylinder.getAxis());
        } else if (shape instanceof CapsuleCollisionShape) {
            CapsuleCollisionShape capsule = (CapsuleCollisionShape) shape;
            return new CapsuleDebugShape(capsule.getRadius(), capsule.getHeight() + (capsule.getRadius() * 2), capsule.getAxis());
        }

        throw new IllegalArgumentException("Collision shape type not supported:" + shape.getClass());
    }

    private Optional<String> getSelectedItem(ListBox<String> listBox) {
        Integer selection = listBox.getSelectionModel().getSelection();
        return selection != null ? Optional.of(listBox.getModel().get(selection)) : Optional.empty();
    }

    private Window createWindow(String title) {
        Container contents = new Container();
        Container listBoxContainer = contents.addChild(new Container());
        ListBox<String> categories = listBoxContainer.addChild(new ListBox<>());
        categories.setVisibleItems(15);
        shapeNames.stream().map(s -> s.split("-")[0]).distinct().forEach(c -> categories.getModel().add(c));

        ListBox<String> models = listBoxContainer.addChild(new ListBox<>(), 1);
        models.setCellRenderer(new ModelIconRenderer(new Vector2f(100, 100)));
        models.setVisibleItems(3);

        AlphanumComparator ac = new AlphanumComparator();

        categories.addCommands(ListBox.ListAction.Down, source -> getSelectedItem(categories).ifPresent(category ->  {
            models.getModel().clear();
            shapeNames.stream().filter(s -> s.startsWith(category)).forEach(s -> models.getModel().add(s));
            Collections.sort(models.getModel(), ac::compare);
        }));

        Container inputs = contents.addChild(new Container());
        TextField nameField = inputs.addChild(new TextField(""));
        inputs.addChild(new Button("Create")).addClickCommands(source -> {
            if (nameField.getText().trim().isEmpty()) {
                return;
            }
            if (shapeNames.contains(nameField.getText())) {
                getState(OptionPanelState.class).show("Error", "Shape with this name already exists.",
                        new EmptyAction("OK"));
                return;
            }

            saveShape(new EmptyShape(true), nameField.getText());
            shapeNames.add(nameField.getText());
            String category = nameField.getText().split("-")[0];
            if (!categories.getModel().contains(category)) {
                categories.getModel().add(category);
            }

            getSelectedItem(categories).ifPresent(s -> {
                if (s.equals(category)) {
                    models.getModel().add(nameField.getText());
                }
            });
        });

        inputs.addChild(new Button("Remove")).addClickCommands(source -> getSelectedItem(models).ifPresent(shapeName -> {
            URL resource = getClass().getResource("/CollisionShapes/" + shapeName.concat(".sav"));
            File f = new File(decodeUrl(resource));
            f.delete();

            shapeNames.remove(shapeName);
            models.getModel().remove(shapeName);
        }));

        inputs.addChild(new Button("Edit")).addClickCommands(source -> getSelectedItem(models).ifPresent(shapeName ->
                createWidget(shapeName)));

        return new JmeWindow(title, contents) {
            public void close() { setEnabled(false); }
        };
    }

    private String getModelPath(String shapeName) {
        String[] strings = shapeName.split("-");
        String category = strings[0];
        String modelNumber = strings[1];
        return "Models/" + category + "/" + modelNumber + ".j3o";
    }

    private Spatial loadModel(String shapeName) {
        return getApplication().getAssetManager().loadModel(getModelPath(shapeName));
    }

    private String decodeUrl(URL url) {
        return URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8);
    }

    private RollupPanel createWidget(String shapeName) {
        if(widget != null) {
            closeWidget();
        }

        Container contents = new Container();
        contents.addChild(new Checkbox("Compound", compoundShape = new DefaultCheckboxModel() {
            @Override
            public void setChecked(boolean state) {
                if(!state && !shapeMap.isEmpty()) {
                    return;
                }

                super.setChecked(state);
            }
        }));

        ListBox<String> physicShapes = contents.addChild(new ListBox<>());
        physicShapes.setVisibleItems(4);
        physicShapes.getChild("selectorArea").removeFromParent();
        Container properties = contents.addChild(new Container());;
        properties.setName("Properties");

        Button save = contents.addChild(new Button("Save"));
        contents.addChild(new ActionButton(new CallMethodAction("Close", this, "closeWidget")));

        CollisionShape shape = loadShape(shapeName);
        if(shape instanceof CompoundCollisionShape) {
            Matrix3f tmpRotation = new Matrix3f();
            Vector3f tmpOffset = new Vector3f();

            for (ChildCollisionShape child : ((CompoundCollisionShape) shape).listChildren()) {
                DebugShape debugShape = createDebugShape(child.getShape());
                Spatial debugSpatial = debugShape.getDebugSpatial();

                // apply scaled offset
                child.copyOffset(tmpOffset);
                debugSpatial.setLocalTranslation(tmpOffset);

                // apply rotation
                child.copyRotationMatrix(tmpRotation);
                debugSpatial.setLocalRotation(tmpRotation);

                debugNode.attachChild(debugSpatial);
                shapeMap.put(debugSpatial, debugShape);
            }
            compoundShape.setChecked(true);
        } else if(!(shape instanceof EmptyShape)) {
            DebugShape debugShape = createDebugShape(shape);
            Spatial debugSpatial = debugShape.getDebugSpatial();
            debugNode.attachChild(debugSpatial);
            shapeMap.put(debugSpatial, debugShape);
            properties.clearChildren();
            properties.addChild(debugShape.getProperties());
        }

        Spatial model = loadModel(shapeName);
        modelRoot.attachChild(model);

        physicShapes.setCellRenderer(new DefaultCellRenderer<>() {
            @Override
            public Panel getView(String value, boolean selected, Panel existing) {
                if( existing == null ) {
                    existing = new Button(value);
                } else {
                    ((Button) existing).setText(value);
                }
                return existing;
            }
        });
        physicShapes.getModel().addAll(Arrays.asList("Box", "Sphere", "Cylinder", "Capsule"));
        physicShapes.addCommands(ListBox.ListAction.Down, source -> {
            Integer selection = physicShapes.getSelectionModel().getSelection();
            if(selection != null) {
                DebugShape debugShape = null;
                switch (physicShapes.getModel().get(selection)) {
                    case "Box":
                        debugShape = new BoxDebugShape(Vector3f.UNIT_XYZ.clone());
                        break;
                    case "Sphere":
                        debugShape = new SphereDebugShape(0.5f);
                        break;
                    case "Cylinder":
                        debugShape = new CylinderDebugShape(0.5f, 1, PhysicsSpace.AXIS_Y);
                        break;
                    case "Capsule":
                        debugShape = new CapsuleDebugShape(0.5f, 1, PhysicsSpace.AXIS_Y);
                        break;
                }

                Spatial debugSpatial = debugShape.getDebugSpatial();
                if(compoundShape.isChecked()) {
                    getState(SelectionAppState.class).getSelectionModel().setSingleSelection(debugSpatial);
                } else {
                    debugNode.detachAllChildren();
                    shapeMap.clear();
                }

                shapeMap.put(debugSpatial, debugShape);
                debugNode.attachChild(debugSpatial);
                properties.clearChildren();
                properties.addChild(debugShape.getProperties());
            }
        });

        save.addClickCommands(source -> {
            if(shapeMap.isEmpty()) {
                getState(OptionPanelState.class).show("", "Model has no collision shape.", new EmptyAction("OK"));
                return;
            }

            if(compoundShape.isChecked()) {
                CompoundCollisionShape ccs = new CompoundCollisionShape();
                shapeMap.values().forEach(debugShape ->
                        ccs.addChildShape(debugShape.createCollisionShape(), debugShape.getDebugSpatial().getLocalTransform()));
                saveShape(ccs, shapeName);
            } else {
                DebugShape debugShape = shapeMap.values().iterator().next();
                saveShape(debugShape.createCollisionShape(), shapeName);
            }

            closeWidget();
        });

        return widget = getState(WidgetPanelState.class).add(new RollupPanel("Properties", contents, null));
    }

    private void closeWidget() {
        getState(WidgetPanelState.class).remove(widget);
        getState(SelectionAppState.class).getSelectionModel().clear();
        shapeMap.clear();
        modelRoot.detachAllChildren();
        debugNode.detachAllChildren();
    }

    private abstract class DebugShape {

        protected final Node debugNode = new Node();

        public abstract CollisionShape createCollisionShape();

        public abstract PropertyPanel getProperties();

        public Node getDebugSpatial() {
            return debugNode;
        }

        protected void updateDebugShape() {
            debugNode.detachAllChildren();
            Spatial debugShape = DebugShapeFactory.getDebugShape(createCollisionShape());
            debugShape.setMaterial(debugMat);
            debugNode.attachChild(debugShape);
        }
    }

    protected class BoxDebugShape extends DebugShape {
        private final PropertyPanel properties;
        private float extentX, extentY, extentZ;

        public BoxDebugShape (Vector3f extents) {
            this.extentX = extents.x;
            this.extentY = extents.y;
            this.extentZ = extents.z;
            properties = new PropertyPanel(null);
            properties.addFloatProperty("X", this, "extentX", 0, 10, 0.05f);
            properties.addFloatProperty("Y", this, "extentY", 0, 10, 0.05f);
            properties.addFloatProperty("Z", this, "extentZ", 0, 10, 0.05f);
            updateDebugShape();
        }

        public float getExtentX() {
            return extentX;
        }

        public void setExtentX(float extentX) {
            this.extentX = extentX;
            updateDebugShape();
        }

        public float getExtentY() {
            return extentY;
        }

        public void setExtentY(float extentY) {
            this.extentY = extentY;
            updateDebugShape();
        }

        public float getExtentZ() {
            return extentZ;
        }

        public void setExtentZ(float extentZ) {
            this.extentZ = extentZ;
            updateDebugShape();
        }

        @Override
        public CollisionShape createCollisionShape() {
            return new BoxCollisionShape(getHalfExtents());
        }

        @Override
        public PropertyPanel getProperties() {
            return properties;
        }

        private Vector3f getHalfExtents() {
            return new Vector3f(extentX, extentY, extentZ).divideLocal(2);
        }

    }

    protected class SphereDebugShape extends DebugShape {
        private final PropertyPanel properties;
        private float radius;

        public SphereDebugShape (float radius) {
            this.radius = radius;
            properties = new PropertyPanel(null);
            properties.addFloatProperty("Radius", this, "radius", 0, 5, 0.01f);
            updateDebugShape();
        }

        public float getRadius() {
            return radius;
        }

        public void setRadius(float radius) {
            this.radius = radius;
            updateDebugShape();
        }

        @Override
        public CollisionShape createCollisionShape() {
            return new SphereCollisionShape(radius);
        }

        @Override
        public PropertyPanel getProperties() {
            return properties;
        }
    }

    protected class CylinderDebugShape extends DebugShape {
        private final PropertyPanel properties;
        private float radius, height;
        private int axis;

        public CylinderDebugShape (float radius, float height, int axis) {
            this.radius = radius;
            this.height = height;
            this.axis = axis;
            properties = new PropertyPanel(null);
            properties.addFloatProperty("Radius", this, "radius", 0, 5, 0.01f);
            properties.addFloatProperty("Height", this, "height", 0, 5, 0.01f);
            properties.addIntProperty("Axis", this, "axis", 0, 2, 1);
            updateDebugShape();
        }

        public float getRadius() {
            return radius;
        }

        public void setRadius(float radius) {
            this.radius = radius;
            updateDebugShape();
        }

        public float getHeight() {
            return height;
        }

        public void setHeight(float height) {
            this.height = height;
            updateDebugShape();
        }

        public int getAxis() {
            return axis;
        }

        public void setAxis(int axis) {
            this.axis = axis;
            updateDebugShape();
        }

        @Override
        public CollisionShape createCollisionShape() {
            return new CylinderCollisionShape(radius, height, axis);
        }

        @Override
        public PropertyPanel getProperties() {
            return properties;
        }
    }

    protected class CapsuleDebugShape extends CylinderDebugShape {

        public CapsuleDebugShape(float radius, float height, int axis) {
            super(radius, height, axis);
        }

        @Override
        public CollisionShape createCollisionShape() {
            return new CapsuleCollisionShape(getRadius(), Math.max(getHeight() - (getRadius() * 2), 0), getAxis());
        }
    }

    private class ShapeRemoveListener implements PropertyChangeListener {

        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            if(evt.getPropertyName().equals(SelectionModel.CONTENT_PROPERTY)) {
                ObservableList.ElementEvent elementEvent = (ObservableList.ElementEvent) evt;
                if(ObservableMap.ChangeType.resolve(elementEvent.getType()) == ObservableMap.ChangeType.REMOVED) {
                    shapeMap.remove(evt.getOldValue());
                }
            }
        }
    }
}

2 Likes

thank you very much @Ali_RS

2 Likes

You’re welcome.

1 Like

If you want to scale a capsule arbitrarily, the easiest solution would be to use MultiSphere with 2 equal-sized spheres. There’s a constructor for that purpose:

2 Likes

Thanks Stephen, I’ll do some tests with Multi Sphere too. :+1:

1 Like