RubikFX:用JavaFX 3D解决魔方难题

翻译:周克非

来源:JPereda's Coding Blog

原文地址:  http://www.tuicool.com/articles/hit/reYf6rq

主题: JavaFX

自从上一次出版到现在已经有一段时间了。但是其间,三次重要的会议让我远离了博客。我对自己说,针对我说过的题目,我必须写博客,但是我根本没有机会。

现在,离JavaOne发布还比较遥远,而且我已完成我的新书  《JavaFX8,Introduction by Example》,该书与我的朋友Carl Dea, Gerrit Grunwald, Mark Heckler and Sean Phillips共同编写完成,很快就会印刷出版。我有机会使用Java 8和JavaFX 3D几个星期,这篇文章就是我探索的结果。

很偶然我的孩子最近在家摆弄魔方,为了感谢源自David Gilday的这个令人惊讶的项目,我们建立一个乐高头脑风暴EV3处理机器人,David Gilday在CubeStormer3中最快复原了魔方,EV3见下图。

在我玩魔方一段时间后,我在考虑创建JavaFX应用程序解决魔方难题的可行性,以及RubikFX如何诞生的问题。如果你急切地想了解RubikFX是怎么一回事,以下YouTube上的视频将向你展示大部分内容。https://www.youtube.com/watch?v=ZVPIBkDgZV4

总体上讲,在这篇文章中我将讨论在一个Scene中导入3D模型,使用放大、旋转、缩放的功能,加入光线,移动摄像头等等。一旦我们有一个非常好的3D魔方模型,我们将努力找到一种独立于模型剩下的部分方式,移动模型层块,保留变化的轨迹。最后,我们将加入通过鼠标点击,选择面、转动层块。

如果你不熟悉魔方的概念和复原它的基本步骤,请阅读以下内容。

在我们开始以前

你也许知道Java8已经在3月18日发布了,所以本项目的代码是基于这个版本的。如果你还没有Java8,请下载新的SDK更新你的系统。我采用NetBeans8.0开发本项目,它支持lambdas表达式和新的Stream API。你可以从以下链接更新你的IDE。地址:https://netbeans.org/downloads/

我使用了2个独立的构件,一个用来导入模型,它是OpenJFX项目的一部分,来源于一个实验性的项目3DViewer。我们需要下载和编译它。另一个来源于ControlsFX项目,它可以为应用程序添加酷炫的对话框。

下载地址:http://fxexperience.com/downloads/controlsfx-8.0.5.zip

最后,我们的项目需要一个3D模型,你可以自己建立一个或者使用一个免费的,你可以从下面的地址下载,采用3ds或者OBJ的格式下载。

地址如下:

http://tf3dm.com/3d-model/rubik39s-cube-79189.html

(原作者提供的下载地址,据我尝试,该文件不能下载了。但是完整版的资源文件中,已经有这个3D模型了)

一旦你得到所有的组件,你可以很容易地得到这张图片。

用3DViewer程序打开3D模型

解压文件,将‘Rubik's Cube.mtl'重命名为‘Cube.mtl',将'Rubik's Cube.obj'重命名为'Cube.obj',编辑该文件将第三行改为‘mtllib Cube.mtl',(用文本编辑器打开)然后保存。运行3DViewer应用程序,将'Cube.obj'拖到viewer中。打开设置面板,选择灯光,打开白色环境光源,关掉puntual(好像是拼错了)光源。你可以放大或者缩小(使用鼠标滚轮、右击、导航条)。旋转魔方用左键(编辑旋转速度用CTRL或者SHIFT),或者用鼠标按键全部按下。

选择Options面板,点击Wireframe,你可以看到建造模型的三角面块。

27个立方体中的每一个都在OBJ文件中给予了一个名称,类似‘Block46’等。所有的三角面块都被集合在一起,且赋予了材质,每个立方体由1到6个面块组成,名字类似‘Block46', ‘Block46 (2)',总共有117个面块。
The color of each cubie meshes is asigned in the 'Cube.mtl' file with the Kd constant relative to the diffuse color.

每个面块的颜色都包含在'Cube.mtl'文件中,漫反射颜色也包含其中。

魔方—精简版

输入3D模型

当我们了解了我们的模型是怎么回事,我们就需要为面块(MeshView)建造节点了(Node)了。3DViewer中的ObjImporter类提供了getMeshes()方法,返回每个面块的名字。我们定义一个HashMap为每个MeshView绑定一个名字,针对每个面块的名字,我们用bulidMeshView(s)方法得到一个MeshView对象。

在设计时,模型中立方体的材质并不反射光线,我们修改它,允许它和Puntual光线交互,通过定义它为PhongMaterial类,编辑材质的特殊粒子属性。

最后,我们将旋转初始魔方,让白色面朝上,蓝色面朝前。

public class Model3D {

/* Cube.obj contains 117 meshes, marked as "Block46",...,"Block72 (6)" in this set:
Cube.obj包含117个mesh面,标识为“Block46”、“Block72 (6)”
*/
private Set<String> meshes;

/* HashMap to store a MeshView of each mesh with its key */
/*哈希表用键值对存储MeshView*/
private final Map<String,Meshview> mapMeshes=new HashMap<>();

public void importObj(){
try {// cube.obj
ObjImporter reader = new ObjImporter(getClass().getResource("Cube.obj").toExternalForm());
meshes=reader.getMeshes(); // set with the names of 117 meshes
//得到117mesh面

Affine affineIni=new Affine();
affineIni.prepend(new Rotate(-90, Rotate.X_AXIS));
affineIni.prepend(new Rotate(90, Rotate.Z_AXIS));
meshes.stream().forEach(s-> {
MeshView cubiePart = reader.buildMeshView(s);
// every part of the cubie is transformed with both rotations:
//魔方的每个部分都要一起变化
cubiePart.getTransforms().add(affineIni);
// since the model has Ns=0 it doesn't reflect light, so we change it to 1
//当NS=0,它不反射光线,我们将它置为1
PhongMaterial material = (PhongMaterial) cubiePart.getMaterial();
material.setSpecularPower(1);
cubiePart.setMaterial(material);
// finally, add the name of the part and the cubie part to the hashMap:
//最后,将魔方每一部份的名字加入hashMap中。
mapMeshes.put(s,cubiePart);
});
} catch (IOException e) {
System.out.println("Error loading model "+e.toString());
}
}
public Map<String, MeshView> getMapMeshes() {
return mapMeshes;
}
}

模型导入后自身是白色面朝右(X轴)红色面朝前(Z轴),这就要求2次旋转。

第一次绕X轴旋转-90度,把蓝色面置前,然后绕Z轴旋转90度把白色面置顶。
(魔方的初始状态是白色面朝上,蓝色面朝前)

数学计算上(矩阵运算),第二次绕Z轴旋转的矩阵必须在左边乘以第一次绕X轴旋转的矩阵。但是按照如下情况,如果我们使用add或者append方法在右侧操作,将产生错误。

链接地址:http://hg.openjdk.java.net/openjfx/8/master/rt/file/f89b7dc932af/modules/graphics/src/main/java/javafx/scene/transform/Affine.java

cubiePart.getTransforms().addAll(new Rotate(-90, Rotate.X_AXIS),new Rotate(90, Rotate.Z_AXIS));
cubiePart.getTransforms().addAll(new Rotate(90, Rotate.Z_AXIS),new Rotate(-90, Rotate.X_AXIS));

如果我们先在Z轴旋转,然后在X轴旋转,把红色面置顶黄色面置前,也会产生错误。

尽管进行右侧旋转,将需要次数更多地旋转,使魔方恢复到初始状态,这也比旋转脱离最后状态更加复杂。

所以prepend是正确可行的方法,我们需要采取把最后一次旋转的矩阵记录到Affine矩阵中,而且Affine矩阵中记录所有之前的旋转状态。

处理模型
在导入obj文件后,我们得到每个小立方体的编号,当魔方正确放置后(白色面朝上,蓝色面朝前),程序中我们将用一个List存储27个项目。

第一组9个索引是前面的9个方块,从左上(R/W/B 颜色)到右下(Y/O/B 颜色)。

第二组9个索引是中间的立面,从左上(R/W)到右下(Y/O)。

第三组9个索引是后面,从左上(G/R/W)到右下(G/Y/O).。

但是对于操作立方体的旋转,最好的办法是使用整形的3维数组。

private final int[][][] cube={{{50,51,52},{49,54,53},{59,48,46}},
{{58,55,60},{57,62,61},{47,56,63}},
{{67,64,69},{66,71,70},{68,65,72}}};

where 50 is the number of the R/W/B cubie and 72 is the number for the G/Y/O.

这里50号是R/W/B小立方块,72号是G/Y/O小立方块。
(R-RED,W-WHITE,B-BLUE,G-GREEN,Y-YELLOW,O-ORANGE)

旋转类将处理面的旋转。

F-S-B

U-E-D

L-M-R   LEFT-MIDDLE-RIGHT

前F后B左L右R上U下D

S-second

E-embed

M—MIDDLE

/* This is the method to perform any rotation on the 3D array just by swapping indexes */
/*这种方法通过交换索引实现3D数组的旋转*/
// first index refers to faces F-S-B
// second index refers to faces U-E-D
// third index refers to faces L-M-R
public void turn(String rot){
int t = 0;
for(int y = 2; y >= 0; --y){
for(int x = 0; x < 3; x++){
switch(rot){
case "L":  tempCube[x][t][0] = cube[y][x][0]; break;
case "Li": tempCube[t][x][0] = cube[x][y][0]; break;
case "M":  tempCube[x][t][1] = cube[y][x][1]; break;
case "Mi": tempCube[t][x][1] = cube[x][y][1]; break;
case "R":  tempCube[t][x][2] = cube[x][y][2]; break;
case "Ri": tempCube[x][t][2] = cube[y][x][2]; break;
case "U":  tempCube[t][0][x] = cube[x][0][y]; break;
case "Ui": tempCube[x][0][t] = cube[y][0][x]; break;
case "E":  tempCube[x][1][t] = cube[y][1][x]; break;
case "Ei": tempCube[t][1][x] = cube[x][1][y]; break;
case "D":  tempCube[x][2][t] = cube[y][2][x]; break;
case "Di": tempCube[t][2][x] = cube[x][2][y]; break;
case "F":  tempCube[0][x][t] = cube[0][y][x]; break;
case "Fi": tempCube[0][t][x] = cube[0][x][y]; break;
case "S":  tempCube[1][x][t] = cube[1][y][x]; break;
case "Si": tempCube[1][t][x] = cube[1][x][y]; break;
case "B":  tempCube[2][t][x] = cube[2][x][y]; break;
case "Bi": tempCube[2][x][t] = cube[2][y][x]; break;
}
}
t++;
}

save();
}

相似的操作可以在整个魔方上实现,X,Y,Z。

模型内容
当我们有了模型,我们需要一个场景显示它。我们使用SubScene对象作为Content容器,引入一个ContentModel类,同时相机、光线、方向轴都被加入,这都来源于3DViewer的应用。

public class ContentModel {
public ContentModel(double paneW, double paneH, double dimModel) {
this.paneW=paneW;
this.paneH=paneH;
this.dimModel=dimModel;
buildCamera();
buildSubScene();
buildAxes();
addLights();
}

private void buildCamera() {
camera.setNearClip(1.0);
camera.setFarClip(10000.0);
camera.setFieldOfView(2d*dimModel/3d);
camera.getTransforms().addAll(yUpRotate,cameraPosition,
cameraLookXRotate,cameraLookZRotate);
cameraXform.getChildren().add(cameraXform2);
cameraXform2.getChildren().add(camera);
cameraPosition.setZ(-2d*dimModel);
root3D.getChildren().add(cameraXform);

/* Rotate camera to show isometric view X right, Y top, Z 120º left-down from each */
/*旋转相机显示等轴侧图,X轴在右,Y轴在上,Z轴在左下120度/
cameraXform.setRx(-30.0);
cameraXform.setRy(30);

}

private void buildSubScene() {
root3D.getChildren().add(autoScalingGroup);

subScene = new SubScene(root3D,paneW,paneH,true,javafx.scene.SceneAntialiasing.BALANCED);
subScene.setCamera(camera);
subScene.setFill(Color.CADETBLUE);
setListeners(true);
}

private void buildAxes() {
double length = 2d*dimModel;
double width = dimModel/100d;
double radius = 2d*dimModel/100d;
final PhongMaterial redMaterial = new PhongMaterial();
redMaterial.setDiffuseColor(Color.DARKRED);
redMaterial.setSpecularColor(Color.RED);
final PhongMaterial greenMaterial = new PhongMaterial();
greenMaterial.setDiffuseColor(Color.DARKGREEN);
greenMaterial.setSpecularColor(Color.GREEN);
final PhongMaterial blueMaterial = new PhongMaterial();
blueMaterial.setDiffuseColor(Color.DARKBLUE);
blueMaterial.setSpecularColor(Color.BLUE);

Sphere xSphere = new Sphere(radius);
Sphere ySphere = new Sphere(radius);
Sphere zSphere = new Sphere(radius);
xSphere.setMaterial(redMaterial);
ySphere.setMaterial(greenMaterial);
zSphere.setMaterial(blueMaterial);

xSphere.setTranslateX(dimModel);
ySphere.setTranslateY(dimModel);
zSphere.setTranslateZ(dimModel);

Box xAxis = new Box(length, width, width);
Box yAxis = new Box(width, length, width);
Box zAxis = new Box(width, width, length);
xAxis.setMaterial(redMaterial);
yAxis.setMaterial(greenMaterial);
zAxis.setMaterial(blueMaterial);

autoScalingGroup.getChildren().addAll(xAxis, yAxis, zAxis);
autoScalingGroup.getChildren().addAll(xSphere, ySphere, zSphere);
}

private void addLights(){
root3D.getChildren().add(ambientLight);
root3D.getChildren().add(light1);
light1.setTranslateX(dimModel*0.6);
light1.setTranslateY(dimModel*0.6);
light1.setTranslateZ(dimModel*0.6);
}
}

对于相机,一个来源于3DViewer的Xform类被用于简单地改变它的旋转数值。这也允许相机的初始旋转显示一个等轴的视口。

cameraXform.setRx(-30.0);
cameraXform.setRy(30);

Other valid ways to perform these rotations could be based on obtaining the vector and angle of rotation to combine two rotations, which involve calculate the rotation matrix first and then the vector and angle (as I explainedhere):

其它有效地执行旋转的方式都基于结合两次旋转间向量、角度的获取,这些都包含了旋转矩阵、向量、角度的计算(我在这解释一下。)

camera.setRotationAxis(new Point3D(-0.694747,0.694747,0.186157));
camera.setRotate(42.1812);

或者用prepend方法把两次旋转和所有之前的变换记录下来,在记录两次旋转之前,把之前的所有项加入到一个单独的Affine矩阵中,最后执行。

Affine affineCamIni=new Affine();
camera.getTransforms().stream().forEach(affineCamIni::append);
affineCamIni.prepend(new Rotate(-30, Rotate.X_AXIS));
affineCamIni.prepend(new Rotate(30, Rotate.Y_AXIS));

我们给subscene添加监听器,那么相机可以很容易地旋转。

private void setListeners(boolean addListeners){
if(addListeners){
subScene.addEventHandler(MouseEvent.ANY, mouseEventHandler);
} else {
subScene.removeEventHandler(MouseEvent.ANY, mouseEventHandler);
}
}

private final EventHandler<MouseEvent> mouseEventHandler = event -> {
double xFlip = -1.0, yFlip=1.0; // y Up
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
mousePosX = event.getSceneX();
mousePosY = event.getSceneY();
mouseOldX = event.getSceneX();
mouseOldY = event.getSceneY();

} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
double modifier = event.isControlDown()?0.1:event.isShiftDown()?3.0:1.0;

mouseOldX = mousePosX;
mouseOldY = mousePosY;
mousePosX = event.getSceneX();
mousePosY = event.getSceneY();
mouseDeltaX = (mousePosX - mouseOldX);
mouseDeltaY = (mousePosY - mouseOldY);

if(event.isMiddleButtonDown() || (event.isPrimaryButtonDown() && event.isSecondaryButtonDown())) {
cameraXform2.setTx(cameraXform2.t.getX() + xFlip*mouseDeltaX*modifierFactor*modifier*0.3);
cameraXform2.setTy(cameraXform2.t.getY() + yFlip*mouseDeltaY*modifierFactor*modifier*0.3);
}
else if(event.isPrimaryButtonDown()) {
cameraXform.setRy(cameraXform.ry.getAngle() - yFlip*mouseDeltaX*modifierFactor*modifier*2.0);
cameraXform.setRx(cameraXform.rx.getAngle() + xFlip*mouseDeltaY*modifierFactor*modifier*2.0);
}
else if(event.isSecondaryButtonDown()) {
double z = cameraPosition.getZ();
double newZ = z - xFlip*(mouseDeltaX+mouseDeltaY)*modifierFactor*modifier;
cameraPosition.setZ(newZ);
}
}
};

处理模型
现在我们可以把所有东西放在一起创建Rubik类了,此时3D模型已导入,所有的meshviews面已经创建了且集合在cube中,然后添加到内容子场景中。同时,ROT已经实例化在在小立方体的初始位置。

instantiate :实例化

public class Rubik {
public Rubik(){
/* Import Rubik's Cube model and arrows */
//导入魔方、箭头模型
Model3D model=new Model3D();
model.importObj();
mapMeshes=model.getMapMeshes();
cube.getChildren().setAll(mapMeshes.values());
dimCube=cube.getBoundsInParent().getWidth();

/* Create content subscene, add cube, set camera and lights */
//创建场景、添加魔方模型、设置相机、光线
content = new ContentModel(800,600,dimCube);
content.setContent(cube);

/* Initialize 3D array of indexes and a copy of original/solved position */
//初始化顺序索引的3维数组,拷贝原始、复位后的位置

rot=new Rotations();
order=rot.getCube();

/* save original position */
//保存原始位置
mapMeshes.forEach((k,v)->mapTransformsOriginal.put(k, v.getTransforms().get(0)));
orderOriginal=order.stream().collect(Collectors.toList());

/* Listener to perform an animated face rotation */
//监听动作面的旋转
rotMap=(ov,angOld,angNew)->{
mapMeshes.forEach((k,v)->{
layer.stream().filter(l->k.contains(l.toString()))
.findFirst().ifPresent(l->{
Affine a=new Affine(v.getTransforms().get(0));
a.prepend(new Rotate(angNew.doubleValue()-angOld.doubleValue(),axis));
v.getTransforms().setAll(a);
});
});
};
}
}

最后对于旋转层的小立方体,我们在时间轴动画上创建一个监听器。当旋转预计发生在当前小立方体的AFFINE矩阵时,将展现一个平顺的动画,改变的角度在0到90度之间,我们监听时间轴如何在内部插值计算,确保在angNew和angOld之间的旋转。

实现旋转的方法如下:

public void rotateFace(final String btRot){
if(onRotation.get()){
return;
}
onRotation.set(true);

// rotate cube indexes   旋转小立方体的索引编号
rot.turn(btRot);
// get new indexes in terms of blocks numbers from original order
//依据原始顺序中块编号,获取新的索引序号
reorder=rot.getCube();
// select cubies to rotate: those in reorder different from order.
//选择小立方体旋转:所有新顺序与原顺序不同的小立方体
AtomicInteger index = new AtomicInteger();
layer=order.stream()
.filter(o->!Objects.equals(o, reorder.get(index.getAndIncrement())))
.collect(Collectors.toList());
// add central cubie
//加入中心小立方体
layer.add(0,reorder.get(Utils.getCenter(btRot)));
// set rotation axis
//设置旋转轴
axis=Utils.getAxis(btRot);

// define rotation
//定义旋转
double angEnd=90d*(btRot.endsWith("i")?1d:-1d);

rotation.set(0d);
// add listener to rotation changes
//为旋转变化添加监听
rotation.addListener(rotMap);

// create animation
//创建动画
Timeline timeline=new Timeline();
timeline.getKeyFrames().add(
new KeyFrame(Duration.millis(600), e->{
// remove listener
//移除监听器
rotation.removeListener(rotMap);
onRotation.set(false);
},  new KeyValue(rotation,angEnd)));
timeline.playFromStart();

// update order with last list
//更新顺序表
order=reorder.stream().collect(Collectors.toList());
}

RubikFX, Lite Version
魔方,精简版

后面我们将加入更多的特性,但是现在我们创建一个JavaFX应用,使用BorderPane组件,在Pane控件中添加内容,包含按钮的执行旋转功能的工具条。

public class TestRubikFX extends Application {

private final BorderPane pane=new BorderPane();
private Rubik rubik;

@Override
public void start(Stage stage) {
rubik=new Rubik();
// create toolbars
//创建工具条,在上下左右四个方向创建4个工具条
ToolBar tbTop=new ToolBar(new Button("U"),new Button("Ui"),new Button("F"),
new Button("Fi"),new Separator(),new Button("Y"),
new Button("Yi"),new Button("Z"),new Button("Zi"));
pane.setTop(tbTop);
ToolBar tbBottom=new ToolBar(new Button("B"),new Button("Bi"),new Button("D"),
new Button("Di"),new Button("E"),new Button("Ei"));
pane.setBottom(tbBottom);
ToolBar tbRight=new ToolBar(new Button("R"),new Button("Ri"),new Separator(),
new Button("X"),new Button("Xi"));
//工具条自身方向的设定
tbRight.setOrientation(Orientation.VERTICAL);
pane.setRight(tbRight);
ToolBar tbLeft=new ToolBar(new Button("L"),new Button("Li"),new Button("M"),
new Button("Mi"),new Button("S"),new Button("Si"));
tbLeft.setOrientation(Orientation.VERTICAL);
//工具条自身方向的设定
pane.setLeft(tbLeft);

pane.setCenter(rubik.getSubScene());

pane.getChildren().stream()
.filter(n->(n instanceof ToolBar))
.forEach(tb->{
((ToolBar)tb).getItems().stream()
.filter(n->(n instanceof Button))
.forEach(n->((Button)n).setOnAction(e->rubik.rotateFace(((Button)n).getText())));
});
rubik.isOnRotation().addListener((ov,b,b1)->{
pane.getChildren().stream()
.filter(n->(n instanceof ToolBar))
.forEach(tb->tb.setDisable(b1));
});
final Scene scene = new Scene(pane, 880, 680, true);
scene.setFill(Color.ALICEBLUE);
stage.setTitle("Rubik's Cube - JavaFX3D");
stage.setScene(scene);
stage.show();
}
}

下图就是我们完成后的样子。

如果你想对这个应用进行深入的研究,你能够在我的GitHub上找到源代码。

网址如下:https://github.com/jperedadnr/LiteRubikFX

提示你,首先需要添加3DViewer的jar包。

如何运作?举个例子, “F”旋转,我们在rot中应用

// rotate cube indexes
//旋转立方体顺序索引
rot.turn(btRot);
// get new indexes in terms of blocks numbers from original order
//依据原始顺序的Block编号得到新的顺序索引
reorder=rot.getCube();

使用rot.printCube()方法,我们可以得到旋转前(order)和旋转后(reorder)小立方体的编号
order:    50 51 52 49 54 53 59 48 46 || 58 55 60 57 62 61 47 56 63 || 67 64 69 66 71 70 68 65 72
reorder:  59 49 50 48 54 51 46 53 52 || 58 55 60 57 62 61 47 56 63 || 67 64 69 66 71 70 68 65 72
通过比对列表和获取差异项,我们可以知道哪些小立方体必须旋转,当然我们需要添加中间立方体的编号(54),它在列表中保持不变,但是它也应该被旋转。所以我们为9个立方体创建列表层。

// select cubies to rotate: those in reorder different from order.
//选择被旋转的立方体,所有那些新序号与老序号不同的
AtomicInteger index = new AtomicInteger();
layer=order.stream()
.filter(o->!Objects.equals(o, reorder.get(index.getAndIncrement())))
.collect(Collectors.toList());
// add central cubie
//加入中间立方体
layer.add(0,reorder.get(Utils.getCenter(btRot)));
// set rotation axis
//设置旋转轴
axis=Utils.getAxis(btRot);

Utils是一个管理旋转类型数值的类,举个例子。

public static Point3D getAxis(String face){
Point3D p=new Point3D(0,0,0);
switch(face.substring(0,1)){
case "F":
case "S":  p=new Point3D(0,0,1);
break;
}
return p;
}

public static int getCenter(String face){
int c=0;
switch(face.substring(0,1)){
case "F":  c=4;  break;
}
return c;
}

当我们获得了小立方体和旋转轴,现在需要考虑旋转监听器如何工作了。在时间轴中,(预定义)一次EASE_BOTH插值角度增加从0到90度,所以角度增量在开始时比较小,中间变大,结束时又变小。下面是一种可行的增量变化表:

0.125º-3º-4.6º-2.2º-2.48º-...-2.43º-4.78º-2.4º-2.4º-0.55º。对于在angNew中的每个值,监听者rotMap对于小立方体的每一层都会进行一个小的旋转。在HashMap表中,隶属于小立方体的meshviews面,将进行一个相对于前一个Affine矩阵的旋预旋转。

/* Listener to perform an animated face rotation */

rotMap=(ov,angOld,angNew)->{
mapMeshes.forEach((k,v)->{
layer.stream().filter(l->k.contains(l.toString()))
.findFirst().ifPresent(l->{
Affine a=new Affine(v.getTransforms().get(0));
a.prepend(new Rotate(angNew.doubleValue()-angOld.doubleValue(),axis));
v.getTransforms().setAll(a);
});
});
};

在600毫秒内,我们将对40个meshview面进行30到40次小旋转。最后,在旋转完成后,我们只需在最后的小立方体列表中更新顺序,如此我们又可以进行下一次旋转。

魔方—完整版

添加更多的特性
现在我们取得了一个很基础的基于JavaFX应用的作品,现在可以添加更多地特性,例如图形箭头和旋转预览,在旋转执行前显示旋转方向。
打乱和顺序
让我们通过一个打乱的状态开始,在开始复原魔方前打乱魔方。 我们将产生一个序列,这个序列来自一个有效旋转清单中的25次随机动作。

private static final List<String> movements =
Arrays.asList("F", "Fi", "F2", "R", "Ri", "R2",
"B", "Bi", "B2", "L", "Li", "L2",
"U", "Ui", "U2", "D", "Di", "D2");

private String last="V", get="V";
public void doScramble(){
StringBuilder sb=new StringBuilder();
IntStream.range(0, 25).boxed().forEach(i->{
while(last.substring(0, 1).equals(get.substring(0, 1))){
// avoid repeating the same/opposite rotations
//为了避免在相同或相反的位置重复旋转
get=movements.get((int)(Math.floor(Math.random()*movements.size())));
}
last=get;
if(get.contains("2")){
get=get.substring(0,1);
sb.append(get).append(" ");
}
sb.append(get).append(" ");
});
doSequence(sb.toString().trim());
}

然后我们依据每个步骤,执行这个旋转序列。首先我们从字符串中提取旋转字符,转换到可以被使用的名称(例如小写字符,或者i代表逆时针旋转)。

一个监听器将被添加给onRotation,所以仅仅当最后一次旋转完成时,新的旋转才会开始。通过添加第二个监听器给顺序索引,当列表中最后一个完成时,这个监听器将被停止,这样可以确保最后一个旋转正常的完成,同时为了进一步的回放功能,保存旋转过程。

public void doSequence(String list){
onScrambling.set(true);
List<String> asList = Arrays.asList(list.replaceAll("’", "i").replaceAll("'", "i").split(" "));

sequence=new ArrayList<>();
asList.stream().forEach(s->{
if(s.contains("2")){
sequence.add(s.substring(0, 1));
sequence.add(s.substring(0, 1));
} else if(s.length()==1 && s.matches("[a-z]")){
sequence.add(s.toUpperCase().concat("i"));
} else {
sequence.add(s);
}
});
System.out.println("seq: "+sequence);

IntegerProperty index=new SimpleIntegerProperty(1);
ChangeListener<boolean> lis=(ov,b,b1)->{
if(!b1){
if(index.get()<sequence.size()){
rotateFace(sequence.get(index.get()));
} else {
// save transforms
mapMeshes.forEach((k,v)->mapTransformsScramble.put(k, v.getTransforms().get(0)));
orderScramble=reorder.stream().collect(Collectors.toList());
}
index.set(index.get()+1);
}
};
index.addListener((ov,v,v1)->{
if(v1.intValue()==sequence.size()+1){
onScrambling.set(false);
onRotation.removeListener(lis);
count.set(-1);
}
});
onRotation.addListener(lis);
rotateFace(sequence.get(0));
}

提示:为了防止丢失上一次的步骤,我们使用ControlsFX中的一个对话框。

Button bSc=new Button("Scramble");
bSc.setOnAction(e->{
if(moves.getNumMoves()>0){
Action response = Dialogs.create()
.owner(stage)
.title("Warning Dialog")
.masthead("Scramble Cube")
.message( "You will lose all your previous movements. Do you want to continue?")
.showConfirm();
if(response==Dialog.Actions.YES){
rubik.doReset();
doScramble();
}
} else {
doScramble();
}
});

如果你想加载一个序列,像下面的这样,另一个带输入的对话框将被使用。

Button bSeq=new Button("Sequence");
bSeq.setOnAction(e->{
String response;
if(moves.getNumMoves()>0){
response = Dialogs.create()
.owner(stage)
.title("Warning Dialog")
.masthead("Loading a Sequence").lightweight()
.message("Add a valid sequence of movements:\n(previous movements will be discarded)")
.showTextInput(moves.getSequence());
} else {
response = Dialogs.create()
.owner(stage)
.title("Information Dialog")
.masthead("Loading a Sequence").lightweight()
.message( "Add a valid sequence of movements")
.showTextInput();
}
if(response!=null && !response.isEmpty()){
rubik.doReset();
rubik.doSequence(response.trim());
}
});

打乱魔方或者加入一个旋转序列,如下图所示。

时间和步骤记录器
我们现在添加一个Java8中的Date and Time API的计时器。你可以在前一副图片中看到,计时器在底部的工具条中。
为此,我们在RubikFX类中编写如下代码。

private LocalTime time=LocalTime.now();
private Timeline timer;
private final StringProperty clock = new SimpleStringProperty("00:00:00");
private final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());

@Override
public void start(Stage stage) {
...
Label lTime=new Label();
lTime.textProperty().bind(clock);
tbBottom.getItems().addAll(new Separator(),lTime);

timer=new Timeline(new KeyFrame(Duration.ZERO, e->{
clock.set(LocalTime.now().minusNanos(time.toNanoOfDay()).format(fmt));
}),new KeyFrame(Duration.seconds(1)));
timer.setCycleCount(Animation.INDEFINITE);

rubik.isSolved().addListener((ov,b,b1)->{
if(b1){
timer.stop();
}
});

time=LocalTime.now();
timer.playFromStart();
}

为了计数,我们将加入2个类。Move是一个简单的POJO类,拥有一个字符串成员变量,记录旋转的名称。一个长整形成员变量,记录动作的时间戳。Moves类包含一个动作的列表。

public class Moves {
private final List<Move> moves=new ArrayList<>();

public Moves(){
moves.clear();
}

public void addMove(Move m){ moves.add(m); }
public List<Move> getMoves() { return moves; }
public Move getMove(int index){
if(index>-1 && index<moves.size()){
return moves.get(index);
}
return null;
}
public String getSequence(){
StringBuilder sb=new StringBuilder("");
moves.forEach(m->sb.append(m.getFace()).append(" "));
return sb.toString().trim();
}
}

为了加入旋转的编号,我们在RubikFX类中使用如下代码。

private Moves moves=new Moves();

@Override
public void start(Stage stage) {
...
rubik.getLastRotation().addListener((ov,v,v1)->{
if(!v1.isEmpty()){
moves.addMove(new Move(v1, LocalTime.now().minusNanos(time.toNanoOfDay()).toNanoOfDay()));
}
});

Label lMov=new Label();
rubik.getCount().addListener((ov,v,v1)->{
lMov.setText("Movements: "+(v1.intValue()+1));
});
tbBottom.getItems().addAll(new Separator(),lMov);
}

回放

我们可以回放用户进行的已经存放在moves中的动作步骤。为此,我们需要存储魔方打乱后的初始状态,从列表清单中一步接一步地读取旋转动作,并演示它。

public void doReplay(List<Move> moves){
if(moves.isEmpty()){
return;
}
content.resetCam();
//restore scramble
//保存打乱的过程
if(mapTransformsScramble.size()>0){
mapMeshes.forEach((k,v)->v.getTransforms().setAll(mapTransformsScramble.get(k)));
order=orderScramble.stream().collect(Collectors.toList());
rot.setCube(order);
count.set(-1);
} else {
// restore original
//存储原始状态
doReset();
}

onReplaying.set(true);
IntegerProperty index=new SimpleIntegerProperty(1);
ChangeListener

lis=(ov,v,v1)->{
if(!v1 && moves.size()>1){
if(index.get()<moves.size()){
timestamp.set(moves.get(index.get()).getTimestamp());
rotateFace(moves.get(index.get()).getFace());
}
index.set(index.get()+1);
}
};
index.addListener((ov,v,v1)->{
if(v1.intValue()==moves.size()+1){
onReplaying.set(false);
onRotation.removeListener(lis);
acuAngle=0;
}
});
onRotation.addListener(lis);
timestamp.set(moves.get(0).getTimestamp());
rotateFace(moves.get(0).getFace());
}

旋转方向预览:
新特性:3D箭头将显示在旋转面或轴上,指示方向。

实际上,JavaFX 3D API不提供任何建立复杂3D模型的方法。Michael Hoffer制作了一个正在进行中的作品,该作品使用CSG,作者:kudos Michael,可以使用STL格式输出。

你可以对使用CSG建立的模型,进行简单操作、布尔操作。

你也可以使用免费的或商业的3D软件完成这个任务。我用SketchUp Make设计箭头,然后用OBJ格式输出,以便我在魔方中使用源自3DViewer的ObjImporter能导入它,

设计是很快的,当创建的文件没有正确地导入时,它需要手工编辑,将其转换为超过4个顶点的长面。

其它的方法也可以输出文件到3DS格式,从August Lammersdorf中正确地导入。
当我们完成了模型,我们必须添加、缩放、旋转模型,所以我们要在旋转面显示箭头。

For a rotation like 'Ui':

对于一个“Ui”旋转

public void updateArrow(String face, boolean hover){
boolean bFaceArrow=!(face.startsWith("X")||face.startsWith("Y")||face.startsWith("Z"));
MeshView arrow=bFaceArrow?faceArrow:axisArrow;

if(hover && onRotation.get()){
return;
}
arrow.getTransforms().clear();
if(hover){
double d0=arrow.getBoundsInParent().getHeight()/2d;
Affine aff=Utils.getAffine(dimCube, d0, bFaceArrow, face);
arrow.getTransforms().setAll(aff);
arrow.setMaterial(Utils.getMaterial(face));
if(previewFace.get().isEmpty()) {
previewFace.set(face);
onPreview.set(true);
rotateFace(face,true,false);
}
} else if(previewFace.get().equals(face)){
rotateFace(Utils.reverseRotation(face),true,true);
} else if(previewFace.get().equals("V")){
previewFace.set("");
onPreview.set(false);
}
}

当在Utils类中Affine矩阵为当前面计算完成时:

public static Affine getAffine(double dimCube, double d0, boolean bFaceArrow, String face){
Affine aff=new Affine(new Scale(3,3,3));
aff.append(new Translate(0,-d0,0));
switch(face){
case "U":
case "Ui":  aff.prepend(new Rotate(face.equals("Ui")?180:0,Rotate.Z_AXIS));
aff.prepend(new Rotate(face.equals("Ui")?45:-45,Rotate.Y_AXIS));
aff.prepend(new Translate(0,dimCube/2d,0));
break;
}
return aff;
}

为了触发绘制的箭头,我们在工具条的按钮上设置一个基于鼠标悬停监听器。
我们为被选择的面在完全旋转90度之前设置一个小角度旋转(5度)作为预览,通过再次调用rotateFace,设置bPreview=True完成。

FX222

如果用户点击按钮,旋转完成(从5度到90度)。否则旋转取消(从5度到0度)。在两种情况下,都是平滑的动画。

通过点击选择旋转
最后,基于鼠标点击魔方表面,通过可视化的箭头点击演示一个5度的小旋转。如果鼠标能够拖得足够远,当鼠标释放时,完全旋转执行完成。如果鼠标在靠近原点的地方释放,旋转将被取消。

对于这种功能,关键点是能够知道我们用鼠标点击选择的是哪个mesh面。为此,API提供MouseEvent.getPickResult().getIntersectedNode()方法,返回一个魔方上的meshview面。

下一步是找到是哪一个meshview面,且meshview面属于哪一个小立方体。所有的mesh面都有一个名字,像'Block46 (2)',考虑一下我们定义的块体编号。现在我们需要确定我们选择的是哪一个面。为此我们使用mesh面的三角坐标系,为面我们定义了一个平面,对于交错的物体,我们知道了平面的通常方向。提示我们必须使用一系列的变形操来更新。

private static Point3D getMeshNormal(MeshView mesh){
TriangleMesh tm=(TriangleMesh)mesh.getMesh();
float[] fPoints=new float[tm.getPoints().size()];
tm.getPoints().toArray(fPoints);
Point3D BA=new Point3D(fPoints[3]-fPoints[0],fPoints[4]-fPoints[1],fPoints[5]-fPoints[2]);
Point3D CA=new Point3D(fPoints[6]-fPoints[0],fPoints[7]-fPoints[1],fPoints[8]-fPoints[2]);
Point3D normal=BA.crossProduct(CA);
Affine a=new Affine(mesh.getTransforms().get(0));
return a.transform(normal.normalize());
}

public static String getPickedRotation(int cubie, MeshView mesh){
Point3D normal=getMeshNormal(mesh);
String rots=""; // Rx-Ry
switch(cubie){
case 0: rots=(normal.getZ()>0.99)?"Ui-Li":
((normal.getX()<-0.99)?"Ui-F":((normal.getY()>0.99)?"Ui-Li":""));
break;
}
return rots;
}

当我们有了通常的,我们能提供给用户2种旋转(2种可能的旋转)。选择一种执行,我们需要知道用户如何移动他们的鼠标。鼠标坐标系是2D的。

public static String getRightRotation(Point3D p, String selFaces){
double radius=p.magnitude();
double angle=Math.atan2(p.getY(),p.getX());
String face="";
if(radius>=radMinimum && selFaces.contains("-") && selFaces.split("-").length==2){
String[] faces=selFaces.split("-");
// select rotation if p.getX>p.getY
if(-Math.PI/4d<=angle && angle<Math.PI/4d){ // X
face=faces[0];
} else if(Math.PI/4d<=angle && angle<3d*Math.PI/4d){ // Y
face=faces[1];
} else if((3d*Math.PI/4d<=angle && angle<=Math.PI) ||
(-Math.PI<=angle && angle<-3d*Math.PI/4d)){ // -X
face=reverseRotation(faces[0]);
} else { //-Y
face=reverseRotation(faces[1]);
}
System.out.println("face: "+face);
} else if(!face.isEmpty() && radius<radMinimum){ // reset previous face
face="";
}
return face;
}

现在我们有层块可以旋转,当鼠标从第一次点击点拖动后,我们可以产生一个小旋转的预览。如果用户释放鼠标,而且与初始点之间的距离比radClick大,旋转将完成。但是如果鼠标拖动的距离比radMinimun小,旋转将被取消。
下面为这种行为提供一个实现接口EventHandler<MouseEvent>。提示当我们选取面和旋转层块时,我们必须停止照相机旋转。

public EventHandler<MouseEvent> eventHandler=(MouseEvent event)->{
if (event.getEventType() == MouseEvent.MOUSE_PRESSED ||
event.getEventType() == MouseEvent.MOUSE_DRAGGED ||
event.getEventType() == MouseEvent.MOUSE_RELEASED) {

mouseNewX = event.getSceneX();
mouseNewY = -event.getSceneY();

if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
Node picked = event.getPickResult().getIntersectedNode();
if(null != picked && picked instanceof MeshView) {
mouse.set(MOUSE_PRESSED);
cursor.set(Cursor.CLOSED_HAND);
stopEventHandling();
stopEvents=true;
pickedMesh=(MeshView)picked;
String block=pickedMesh.getId().substring(5,7);
int indexOf = order.indexOf(new Integer(block));
selFaces=Utils.getPickedRotation(indexOf, pickedMesh);
mouseIniX=mouseNewX;
mouseIniY=mouseNewY;
myFace="";
myFaceOld="";
}
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
if(stopEvents && !selFaces.isEmpty()){
mouse.set(MOUSE_DRAGGED);
Point3D p=new Point3D(mouseNewX-mouseIniX,mouseNewY-mouseIniY,0);
radius=p.magnitude();

if(myFaceOld.isEmpty()){
myFace=Utils.getRightRotation(p,selFaces);
if(!myFace.isEmpty() && !onRotation.get()){
updateArrow(myFace, true);
myFaceOld=myFace;
}
if(myFace.isEmpty()){
myFaceOld="";
}
}
// to cancel preselection, just go back to initial click point
//取消预选,回到初始点击点

if(!myFaceOld.isEmpty() && radius<Utils.radMinimum){
myFaceOld="";
updateArrow(myFace, false);
myFace="";
}
}
} else if (stopEvents && event.getEventType() == MouseEvent.MOUSE_RELEASED) {
mouse.set(MOUSE_RELEASED);
if(!onRotation.get() && !myFace.isEmpty() && !myFaceOld.isEmpty()){
if(Utils.radClick<radius){
// if hand is moved far away do full rotation
//如果移动足够远,执行完全旋转
rotateFace(myFace);
} else {
// else preview cancellation
//否则预览取消
updateArrow(myFace, false);
}
}
myFace=""; myFaceOld="";
stopEvents=false;
resumeEventHandling();
cursor.set(Cursor.DEFAULT);
}
}
};

最后,我们将EventHandler添加到场景中。

scene.addEventHandler(MouseEvent.ANY, rubik.eventHandler);

下面的图片展示事件处理如何工作。

检查魔方是否复原
最后,我们加入一段检查程序。我们知道小立方体的初始顺序,但是我们需要考虑面的24种方向,我们能够在2次旋转中完成。

private static final List<String> orientations=Arrays.asList("V-V","V-Y","V-Yi","V-Y2",
"Xi-V","Xi-Z","Xi-Zi",
"Z-V","Zi-V","Z2-V");

public static boolean checkOrientation(String r, List<Integer> order){
Rotations rot=new Rotations();
for(String s:r.split("-")){
if(s.contains("2")){
rot.turn(s.substring(0,1));
rot.turn(s.substring(0,1));
} else {
rot.turn(s);
}
}
return order.equals(rot.getCube());
}

所以在每次运动后,我们必须检查当前的块体是否符合24种之一。为此我们用parallelStream()的过滤器,旋转一个新的魔方,来检查是否匹配当前状态。

 
结论

写到这,我们对于JavaFx3D API的讨论已经足够深入了,但是缺乏一些3D模型常用工具的介绍。也许,这些很快就会有。

我们使用新的兰布达表达式和Stream  API。我希望你现在对它有一个清晰的概念。当然,在你写的代码中,他们会有一些变化。

魔方应用为证明新的特性提供了一条途径。我的意思是开发代码。

最后的视频是我完成这篇文章的大多数工作。
在我的repo中,你可以找到完整版的全部代码。自由的修改使用它。当然也会有许多改进,欢迎新的需求。

最后,感谢阅读!欢迎您做出评论。

RubikFX:用JavaFX 3D解决魔方难题相关推荐

  1. OpenAI机器人手创建自己的训练机制自学解决魔方问题

    OpenAI的研究人员开发了一种新方法,可以将复杂的操纵技能从模拟环境转移到物理环境.研发人员已经训练了一对神经网络,可以像人一样的机器人手来解决魔方.使用与OpenAI Five相同的强化学习代码以 ...

  2. 10个顶级商业思维_9个启发 | 如何用设计思维解决商业难题

    小小sha导图卡片屋 第402次分享 分享 | 小小sha(ID :shashalaoshi2020) 排版编辑| 麻吉(ID:Cmj-hellow) 晚上19:30,相约导图卡片屋,今天我们来聊聊如 ...

  3. 商汤联手华科:提出文字检测模型GNNets,新颖模块可解决几何分布难题

    加入「公开课」交流群,获取更多学习资料.课程及热招岗位等信息 编辑 | Jane 出品 | AI科技大本营(ID:rgznai100) [导读]今年的ICCV,商汤科技及联合实验室共有57篇论文入选I ...

  4. 媒体应用大数据,先解决三大难题

    在大数据时代,互联网是骨骼,大数据则是血液.大数据的核心在于数据,具有海量.高频.在线.实时等特点,但是对于传统媒体来说,在运用大数据的过程中,存在着数据资源不足.数据平台欠缺和缺乏有竞争力的数据产品 ...

  5. 史上最硬核文科生,擅长解决数学难题,却视考试成为终生噩梦

    全世界只有3.14 % 的人关注了 青少年数学之旅 "数学存在的价值,不只是为了生活上的应用,它不应沦为供工程.商业应用的工具,数学的突破仍需要不断地去突破现有格局." --节选自 ...

  6. java面试解决项目难题_Java转换难题者,不适合工作(或面试)

    java面试解决项目难题 一个非常艰苦的面试问题可能是这样的: int i = Integer.MAX_VALUE; i += 0.0f; int j = i; System.out.println( ...

  7. 比特币一种点对点的电子现金系统是哪一年诞生的_驭凡学堂 中本聪创造比特币的原因是为了解决技术难题...

    在创造比特币的过程中,中本聪发明了区块链技术,区块链是源自比特币的底层技术.那么,他为什么要创造比特币?他想解决什么难题? 现在,比特币常被称为一种"加密数字货币",人们常很关注其 ...

  8. android 3d魔方 代码,css实现3d立体魔方的示例代码

    今天来做一个简单的3d魔方 先看效果图吧!把这个看会了,一些网上的3d的相册你就都会了 一.我们先准备好们的html代码 3d立体魔方 好了我们html代码就准备完成了,首先我们要有一个3d的思维,在 ...

  9. Html-照片的逐步出现 、心形动画制作、3d立方体魔方、鼠标划过box阴影练习

    Html-照片的逐步出现 .心形动画制作.3d立方体魔方.鼠标划过box阴影练习 一.照片的逐步出现 <!DOCTYPE html> <html lang="en" ...

最新文章

  1. Android中的Selector的用法
  2. linux c之main(int argc, char *argv[], char *envp[])参数意义
  3. 如何注册鸿蒙id,鸿蒙系统真机调试证书 和 设备ID获取
  4. 十分钟让你明白Objective-C的语法(和Java、C++的对比)
  5. 指令系统——数据寻址(2)(详解)
  6. Android—SDCard数据存取Environment简介
  7. sql 会话_在特定会话中禁用SQL Server中的触发器
  8. jquery jqplot pierenderer 饼图百分比小于3的无法显示DataLabels
  9. JS DOM节点的增删改查
  10. 在 Docker 中使用 mysql 的一些技巧 1
  11. 每周分享第 26 期
  12. 为什么mydock会经常崩溃_MyDock
  13. 软件测试面试题:所有的软件缺陷都能修复吗?所有的软件缺陷都要修复吗?
  14. 设计一个H5编辑器的数据模型和核心功能
  15. C++面向对象(三):类和对象
  16. 2019/9/1 ecam5
  17. Kernel API(一)writeb(), writew(), writel(),readb(), readw(), readl()
  18. C# CultureInfo中常用的InvariantCulture
  19. 深度linux跟windows,不服跑个分:深度操作系统Deepin与Win10性能对比测试
  20. 实际开发中,TCP / IP五层网络模型是如何工作的?

热门文章

  1. Tensorflow结点打包和依赖控制
  2. CPU中的Little Endian与Big Endian
  3. java中基本数据类型
  4. 【剑指offer】Java版代码(完整版)
  5. 对Javascript局部变量的理解
  6. Python-----包和日志的使用
  7. Earth Wind and Fire CodeForces - 1148E (构造)
  8. 一天一个小算法的学习之选择排序
  9. GatewayWorker+laravel5.5+layim即时通讯项目demo
  10. go http.Get请求 http.Post请求 http.PostForm请求 Client 超时设置