使用SVG创建平行投影和等角投影的方法以及创建等轴测图

解如何使用SVG创建和操作平行投影和等角投影,以及如何利用JointJS库定义这些对象,以实现三维物体在二维空间中的可视化。掌握平行投影和等角投影的概念和实现方法,为数字艺术家、工程师和建筑师提供了更多样性的创作和展示方式。

在二维空间中可视化三维物体有多种方法。例如,大多数3D图形引擎都使用透视投影作为主要的投影形式。这是因为透视投影是现实世界的绝佳表示,其中物体随着距离的增加而变小。但当物体的相对位置并不重要,并且为了更好地了解物体的大小时,可以使用平行投影。它们在工程和建筑中更常见,保持平行线很重要。自从计算机图形学诞生以来,当 3D 渲染硬件加速无法实现时,这些投影就被用来渲染 3D 场景。最近,各种形式的平行投影已成为数字艺术家的风格选择,它们通常用于显示信息图表和数字艺术中的对象。

本文的目的是展示如何在 SVG 中创建和操作等距视图,以及如何使用(特别是)JointJS 库来定义这些对象。为了说明 SVG 创建平行投影的功能,我们将使用等角投影作为示例。此投影是主要的投影类型之一,因为它允许您保持对象沿所有轴的相对比例。

等角投影

让我们定义什么是等角投影。首先,它是一种平行类型的投影,其中来自“相机”的所有线都是平行的。这意味着物体的比例不取决于“相机”和物体之间的距离。具体来说,在等距(希腊语中的意思是“等量”)投影中,沿每个轴的缩放比例是相同的。这是通过在所有轴之间定义相等的角度来实现的。

在下图中,您可以看到轴在等角投影中的定位方式。请记住,在本文中,我们将使用左手坐标系。

等角投影

等角投影的特点之一是它可以解构为三种不同的二维投影:顶投影、侧投影和正投影。例如,长方体可以由每个 2D 投影上的三个矩形表示,然后组合成一个等角视图。下一个图像表示使用左手坐标系的对象的单独投影。

正交投影的单独视图

正交投影的单独视图

然后,我们可以将它们组合成一个等轴测视图:

示例对象的等距视图

示例对象的等距视图

SVG 面临的挑战是它包含位于一个 XY 平面上的 2D 对象。但我们可以通过将所有投影组合在一个平面上,然后分别对每个对象应用变换来克服这个问题。

SVG 等轴测视图转换

在 3D 中,要创建等距视图,我们可以将相机移动到某个位置,但 SVG 纯粹是 2D 格式,因此我们必须创建一个解决方法来构建这样的视图。根据本文,我们需要分别为对象的每个 2D 投影创建变换。

首先,我们需要将平面旋转 30 度。然后,我们将 2D 图像倾斜 -30 度。此变换将使我们的轴与等角投影的轴对齐。

然后,我们需要使用缩放运算符将 2D 投影垂直缩小 0.8602。由于等角投影失真的事实,我们需要这样做。

让我们介绍一些 SVG 功能,这些功能将帮助我们实现等角投影。SVG 规范允许用户在SVG 元素的transform 属性中指定特定的转换。此属性帮助我们对 SVG 元素应用线性变换。要将 2D 投影转换为等轴测视图,我们需要应用缩放、旋转和倾斜运算符。

为了在代码中表示变换,我们可以使用DOMMatrixReadOnly对象(浏览器 API)来表示变换矩阵。使用这个接口,我们可以创建一个矩阵,如下所示:

const isoMatrix = new DOMMatrixReadOnly()
    .rotate(30)
    .skewX(-30)
    .scale(1, 0.8602);

该接口允许使用我们的值构建变换矩阵,然后我们可以使用 matrix 函数将结果值应用到 transform 属性。

在 SVG 中,我们一次只能呈现一个 2D 空间,因此对于我们的转换,我们将使用顶部投影作为基础投影。这主要是因为该投影中的轴与普通 SVG 视口中的轴相对应。

为了演示 SVG 的可能性,我们将使用 JointJS 库。我们在 XY 平面中定义了一个单元宽度为 20 的矩形网格。让我们为示例中顶部投影上的元素定义 SVG。为了正确渲染该对象,我们需要为对象的两个级别指定两个多边形。此外,我们可以使用 DOMMatrix 在 2D 空间中对元素应用平移转换:

// Top1 元素的平移转换
const matrix2D = new DOMMatrixReadOnly()
    .translate(200, 200);
<!--Top1 element-->
<polygon joint-selector="body" id="v-4" 
  stroke-width="2" stroke="#333333" fill="#ff0000" 
  fill-opacity="0.7" points="0,0 60,0 60,20 40,20 40,60 0,60" 
  transform="matrix(1,0,0,1,200,200)">
</polygon>

<!--Top2 element-->
<polygon joint-selector="body" id="v-6" 
  stroke-width="2" stroke="#333333" fill="#ff0000" 
  fill-opacity="0.7" points="0,0 20,0 20,40 0,40" 
  transform="matrix(1,0,0,1,240,220)">
</polygon>

然后,我们可以将等距矩阵应用于我们的元素。

然后,我们可以将等距矩阵应用于我们的元素。此外,我们将添加一个翻译转换以将元素放置在正确的位置:

const isoMatrix = new DOMMatrixReadOnly()
    .rotate(30)
    .skewX(-30)
    .scale(1, 0.8602);

const top1Matrix = isoMatrix.translate(200, 200);
const top2Matrix = isoMatrix.translate(240, 220);

等距视图,无需高度调整

等距视图,无需高度调整

为简单起见,我们假设元素的基平面位于 XY 平面上。因此,我们需要平移顶视图,以便将其视为位于对象的顶部。为此,我们只需按缩放后的 SVG 空间上的 Z 坐标平移投影,如下所示。Top1 元素的标高为 80,因此我们应该将其平移 (-80, -80)。同样,Top2 元素的标高为 40。我们可以将这些转换应用到现有矩阵:

const top1MatrixWithHeight = top1Matrix.translate(-80, -80);
const top2MatrixWithHeight = top1Matrix.translate(-40, -40);

顶部投影的最终等角视图

顶部投影的最终等角视图

最后,我们将拥有和元素transform的以下属性。请注意,它们仅在最后两个值上有所不同,这两个值表示平移转换:Top1Top2

// Top1 element
transform="matrix(0.8660254037844387,0.49999999999999994,-0.8165000081062317,0.47140649947346464,5.9,116.6)"

// Top2 element
transform="matrix(0.8660254037844387,0.49999999999999994,-0.8165000081062317,0.47140649947346464,26.2,184.9)"

要创建侧面和正面投影的等距视图,我们需要制作一个网,以便可以将所有投影放置在 2D SVG 空间上。让我们通过附加类似于经典立方体网络的侧视图和前视图来创建一个网络:

经典立方网

然后,我们需要将skewX侧面和正面投影成45度。它将允许我们对齐所有投影的 Z 轴。经过这个变换,我们将得到如下图像:

准备好的二维投影

准备好的二维投影

然后,我们可以将 isoMatrix 应用于该对象:

等角投影无需深度调节

等角投影无需深度调节

在每个投影中,都有具有不同第三坐标值的部分。因此,我们需要为每个投影调整深度坐标,就像我们对顶部投影及其 Z 坐标所做的那样。最终我们会得到如下的等轴测图:

物体的最终等距视图

物体的最终等距视图

使用 JointJS 绘制等距图

由于其元素框架和广泛的工具集,JointJS 使我们能够轻松创建和操作此类对象。使用JointJS,我们可以定义和控制等距对象来构建强大的等距图。

还记得文章开头的基本等距变换吗?

const isoMatrix = new DOMMatrixReadOnly()
    .rotate(30)
    .skewX(-30)
    .scale(1, 0.8602);

在 JointJS 库中,我们可以将此转换应用于存储所有 SVG 元素的整个对象,然后简单地在此基础上应用特定于对象的转换。

等距网格渲染

JointJS 在渲染自定义 SVG 标记方面具有强大的功能。利用 JointJS,我们可以生成一条与未转换的网格对齐的路径,并使其随网格自动转换,这要归功于我们之前提到的全局纸张转换。您可以在下面的演示中看到网格以及我们如何解释坐标系。请注意,我们可以动态更改纸张转换,这使我们能够动态更改视图:

等距网格效果图

以上等距网格图片显示代码如下:

<!--css-->
<style>
#paper-container {position:absolute;right:0;top:0;left:0;bottom:0;}
#logo {position:absolute;bottom:20px;right:0;}
label {position:absolute;top:30px;right:30px;font-family:sans-serif;}
label input {vertical-align:text-top;}
</style>
<!--html-->
<div id="paper-container"></div>
<label>
    <span>Isometric Transformation:</span>
    <input type="checkbox" id="isometric-switch" checked />
</label>
<a target="_blank" href="https://www.toymoban.com">
    <img id="logo" src="https://www.toymoban.com/style/defalut/img/logo.png" width="200" height="50"></img>
</a>
<!--JS-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.4.1/backbone-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jointjs@3.7.2/dist/joint.min.js"></script>

<script>
const { dia, shapes, util } = joint;

const GRID_SIZE = 20;
const GRID_COUNT = 12;

// Matrix of the isometric transformation and its parameters

const SCALE = 1;
const ISOMETRIC_SCALE = 0.8602;
const ROTATION_DEGREES = 30;

const transformationMatrix = () => {
    return V.createSVGMatrix()
    .translate(GRID_SIZE * GRID_COUNT, GRID_SIZE)
    .rotate(ROTATION_DEGREES)
    .skewX(-ROTATION_DEGREES)
    .scaleNonUniform(SCALE, SCALE * ISOMETRIC_SCALE);
};

// Paper

const cellNamespace = { ...shapes };

const graph = new dia.Graph({}, { cellNamespace });

const paper = new dia.Paper({
    el: document.getElementById("paper-container"),
    model: graph,
    restrictTranslate: {
    x: 0,
    y: 0,
    width: GRID_SIZE * GRID_COUNT,
    height: GRID_SIZE * GRID_COUNT
    },
    width: "100%",
    height: "100%",
    gridSize: GRID_SIZE,
    async: true,
    autoFreeze: true,
    sorting: dia.Paper.sorting.APPROX,
    cellViewNamespace: cellNamespace
});

// Make the paper isometric by applying the isometric matrix to all
// SVG content it contains.
paper.matrix(transformationMatrix());

const gVEl = V("g", { fill: "#ed2637" });
const rectVEl = V("rect", {
    width: GRID_SIZE,
    height: GRID_SIZE,
    stroke: "#ed2637",
    "stroke-width": 1
});
const textVEl = V("text").attr({
    "text-anchor": "start",
    x: 2 * GRID_SIZE,
    "font-size": GRID_SIZE,
    "font-family": "sans-serif",
    stroke: "white",
    "stroke-width": 3,
    "paint-order": "stroke"
});
gVEl.append([rectVEl, textVEl]);

paper.el.addEventListener(
    "mousemove",
    (evt) => {
    const { x, y } = paper.clientToLocalPoint(evt.clientX, evt.clientY);
    const i = Math.floor(x / GRID_SIZE);
    const j = Math.floor(y / GRID_SIZE);
    drawCoordinates(paper, i, j);
    },
    false
);

drawGrid(paper);
drawCoordinates(paper, 0, 0);

// Add switch to toggle the isometric view with 2d for demonstration purposes

document
    .getElementById("isometric-switch")
    .addEventListener("change", (evt) => {
    if (evt.target.checked) {
        paper.matrix(transformationMatrix());
    } else {
        paper.matrix(
        V.createSVGMatrix().translate(GRID_SIZE * GRID_COUNT, GRID_SIZE)
        );
    }
    });

// A function to draw the grid.
function drawGrid(paper) {
    const gridData = [];
    const j = GRID_COUNT;
    for (let i = 0; i <= j; i++) {
    gridData.push(`M 0,${i * GRID_SIZE} ${j * GRID_SIZE},${i * GRID_SIZE}`);
    gridData.push(`M ${i * GRID_SIZE},0 ${i * GRID_SIZE},${j * GRID_SIZE}`);
    }

    const gridEl = V("path").attr({
    d: gridData.join(" "),
    fill: "none",
    stroke: "lightgray"
    }).node;

    // When the grid is appended to one of the paper's layer, it gets automatically transformed
    // by the isometric matrix
    paper.getLayerNode(dia.Paper.Layers.BACK).append(gridEl);
}

// A function to highlight a point in the grid
function drawCoordinates(paper, i, j) {
    textVEl.text(`x: ${i}  y: ${j}`, { verticalTextAnchor: "middle" });
    gVEl.attr("transform", `translate(${i * GRID_SIZE},${j * GRID_SIZE})`);
    if (i >= 0 && j >= 0 && i < GRID_COUNT && j < GRID_COUNT) {
    if (!gVEl.node.isConnected) {
    gVEl.appendTo(paper.getLayerNode(dia.Paper.Layers.BACK));
    }
    } else {
    gVEl.remove();
    }
}
</script>

创建自定义等距 SVG 元素

在这里,我们在 JointJS 中展示了自定义 SVG 等距形状。在我们的示例中,我们使用该isometricHeight属性来存储有关第三维的信息,然后使用它来渲染我们的等距对象。以下代码片段显示了如何调用自定义createIsometricElement函数来更改对象属性:

const element = createIsometricElement({
    isometricHeight: GRID_SIZE * 3,
    size: { width: GRID_SIZE * 3, height: GRID_SIZE * 6 },
    position: { x: GRID_SIZE * 6, y: GRID_SIZE * 6 }
});

在下面的演示中,您可以看到我们的自定义等距元素可以像等距网格上的普通元素一样移动。createIsometricElement您可以通过更改源代码中函数的参数来更改尺寸(当您单击“在 CodePen 上编辑”时):

等距网格上的自定义等距元素

以上等距网格上的自定义等距元素效果图的代码如下:

<!--css-->
<style>
#paper-container {position:absolute;right:0;top:0;left:0;bottom:0;}
#logo {position:absolute;bottom:20px;right:0;}
label {position:absolute;top:30px;right:30px;font-family:sans-serif;}
label input {vertical-align:text-top;}
</style>
<!--html-->
<div id="paper-container"></div>
<label>
    <span>Isometric Transformation:</span>
    <input type="checkbox" id="isometric-switch" checked />
</label>
<a target="_blank" href="https://www.toymoban.com">
    <img id="logo" src="https://www.toymoban.com/style/defalut/img/logo.png" width="200" height="50"></img>
</a>
<!--JS-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.4.1/backbone-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jointjs@3.7.2/dist/joint.min.js"></script>

<script>
const { dia, shapes, util } = joint;
const GRID_SIZE = 20;
const GRID_COUNT = 12;
// 确保 Z 方向元素可见性的值
const PAPER_Z_OFFSET = GRID_SIZE * 4;
// 等距变换矩阵及其参数
const SCALE = 1;
const ISOMETRIC_SCALE = 0.8602;
const ROTATION_DEGREES = 30;
const transformationMatrix = () => {
    return V.createSVGMatrix()
        .translate(GRID_COUNT * GRID_SIZE * SCALE * ISOMETRIC_SCALE + GRID_SIZE, PAPER_Z_OFFSET + GRID_SIZE)
        .rotate(ROTATION_DEGREES)
        .skewX(-ROTATION_DEGREES)
        .scaleNonUniform(SCALE, SCALE * ISOMETRIC_SCALE);
};
// 这里我们指定元素标记和
// 标记部分的常量属性
const IsometricElement = dia.Element.define(
    'IsometricElement',
    {
        attrs: {
            top1: {
                strokeWidth: 2,
                stroke: '#333333',
                fill: '#ff0000',
                fillOpacity: 0.7,
            },
            top2: {
                strokeWidth: 2,
                stroke: '#333333',
                fill: '#ff0000',
                fillOpacity: 0.7,
            },
            side1: {
                strokeWidth: 2,
                stroke: '#333333',
                fill: '#ffff00',
                fillOpacity: 0.7,
            },
            side2: {
                strokeWidth: 2,
                stroke: '#333333',
                fill: '#ffff00',
                fillOpacity: 0.7
            },
            front1: {
                strokeWidth: 2,
                stroke: '#333333',
                fill: '#0000ff',
                fillOpacity: 0.7,
            },
            front2: {
                strokeWidth: 2,
                stroke: '#333333',
                fill: '#0000ff',
                fillOpacity: 0.7,
            }
        }
    },
    {
        markup: util.svg/* xml */ `
      <polygon @selector="top1"></polygon>
      <polygon @selector="top2"></polygon>
      <polygon @selector="side1"></polygon>
      <polygon @selector="side2"></polygon>
      <polygon @selector="front1"></polygon>
      <polygon @selector="front2"></polygon>
    `
    }
);
// 将尺寸参数转换为标记零件的路径属性
// 这样可以以更灵活的方式创建元素
const createIsometricElement = (properties) => {
    const d = {
        x: properties.size.width,
        y: properties.size.height,
        z: properties.isometricHeight
    };
    properties.attrs = properties.attrs || {};
    properties.attrs.top1 = {
        points: `0,0 ${d.x},0 ${d.x},${GRID_SIZE} ${d.x - GRID_SIZE},${GRID_SIZE} ${d.x - GRID_SIZE},${d.y} 0,${d.y}`,
        transform: `translate(${-d.z}, ${-d.z})`
    };
    properties.attrs.top2 = {
        points: `${d.x - GRID_SIZE},${GRID_SIZE} ${d.x},${GRID_SIZE} ${d.x},${d.y} ${d.x - GRID_SIZE},${d.y}`,
        transform: `translate(${-d.z + GRID_SIZE * 2}, ${-d.z + GRID_SIZE * 2})`
    };
    properties.attrs.side1 = {
        points: `0,0 ${d.x - GRID_SIZE},0 ${d.x - GRID_SIZE},${GRID_SIZE * 2} ${d.x},${GRID_SIZE * 2} ${d.x},${d.z} 0,${d.z}`,
        transform: V.matrixToTransformString(
            new DOMMatrixReadOnly()
                .translate(-d.z, -d.z + d.y)
                .skewX(45)
        )
    };
    properties.attrs.side2 = {
        points: `0,0 ${GRID_SIZE},0 ${GRID_SIZE},${GRID_SIZE * 2} 0,${GRID_SIZE * 2}`,
        transform: V.matrixToTransformString(
            new DOMMatrixReadOnly()
                .translate(-d.z + d.x - GRID_SIZE, -d.z + GRID_SIZE)
                .skewX(45)
        )
    };
    properties.attrs.front1 = {
        points: `0,0 ${d.z},0 ${d.z},${d.y} ${GRID_SIZE * 2},${d.y}, ${GRID_SIZE * 2},${GRID_SIZE} 0,${GRID_SIZE}`,
        transform: V.matrixToTransformString(
            new DOMMatrixReadOnly()
                .translate(-d.z + d.x, -d.z)
                .skewY(45)
        )
    },
    properties.attrs.front2 = {
        points: `0,0 ${GRID_SIZE * 2},0 ${GRID_SIZE * 2},${d.y - GRID_SIZE} 0,${d.y - GRID_SIZE}`,
        transform: V.matrixToTransformString(
            new DOMMatrixReadOnly()
                .translate(-d.z + d.x - GRID_SIZE, -d.z + GRID_SIZE)
                .skewY(45)
        )
    };

    return new IsometricElement(properties);
};
// Paper
const cellNamespace = { ...shapes, IsometricElement };
const graph = new dia.Graph({}, { cellNamespace });
const paper = new dia.Paper({
    el: document.getElementById('paper-container'),
    model: graph,
    restrictTranslate: {
        x: 0,
        y: 0,
        width: GRID_SIZE * GRID_COUNT,
        height: GRID_SIZE * GRID_COUNT
    },
    width: '100%',
    height: '100%',
    gridSize: GRID_SIZE,
    async: true,
    autoFreeze: true,
    sorting: dia.Paper.sorting.APPROX,
    cellViewNamespace: cellNamespace
});
//通过将等距矩阵应用于所有纸张,使纸张等距
// 它包含的 SVG 内容。
paper.matrix(transformationMatrix());
// 将等距元素添加到图形中。
// 您可以使用元素的大小和附加 z 参数指定元素的尺寸
const element = createIsometricElement({
    isometricHeight: GRID_SIZE * 4,
    size: { width: GRID_SIZE * 3, height: GRID_SIZE * 6 },
    position: { x: GRID_SIZE * 6, y: GRID_SIZE * 6 }
});
element.addTo(graph);
// 绘制网格的函数。
drawGrid(paper);
function drawGrid(paper) {
    const gridData = [];
    const j = GRID_COUNT;
    for (let i = 0; i <= j; i++) {
        gridData.push(`M 0,${i * GRID_SIZE} ${j * GRID_SIZE},${i * GRID_SIZE}`);
        gridData.push(`M ${i * GRID_SIZE},0 ${i * GRID_SIZE},${j * GRID_SIZE}`);
    }

    const gridEl = V('path').attr({
        d: gridData.join(' '),
        fill: 'none',
        stroke: 'lightgray'
    }).node;

    //当网格附加到纸张的某一层时,它会自动转换
    // 通过等距矩阵
    paper.getLayerNode(dia.Paper.Layers.BACK).append(gridEl);
}
// 添加开关以切换 2d 等距视图以用于演示目的
document
    .getElementById('isometric-switch')
    .addEventListener('change', (evt) => {
        if (evt.target.checked) {
            paper.matrix(transformationMatrix());
        } else {
            paper.matrix(
                V.createSVGMatrix().translate(
                    GRID_SIZE * GRID_COUNT,
                    PAPER_Z_OFFSET + GRID_SIZE
                )
            );
        }
    });
</script>

等轴测图中的 Z 索引计算

等距视图的问题之一是将元素分别放置到它们的相对位置。与 2D 平面不同,在等轴测视图中,物体具有感知高度,并且可以一个一个地放置在另一个物体后面。我们可以通过将它们以正确的顺序放入 DOM 中来在 SVG 中实现此行为。为了在我们的例子中定义顺序,我们可以使用 JointJSz属性,它允许将正确的元素发送到后台,以便它可以按预期被其他元素重叠/隐藏。您可以在Andreas Hager 撰写的一篇精彩文章中找到有关此问题的更多信息。

我们决定使用拓扑排序算法对元素进行排序。该算法由两个步骤组成。首先,我们需要创建一个特殊的图,然后我们需要对该图使用深度优先搜索来找到元素的正确顺序。

作为第一步,我们需要填充初始图 - 对于每个对象,我们需要找到其背后的所有对象。我们可以通过比较它们底边的位置来做到这一点。让我们用图像来说明此步骤 - 例如,我们采用三个元素,它们的位置如下:

图形

图2

我们在第二张图像中标记了每个对象的底侧。使用这些数据,我们将创建一个图形结构,该结构将建模元素之间的拓扑关系。在图像中,您可以看到我们如何定义底部的点 - 我们可以通过比较aMax和bMin点来找到所有元素的相对位置。我们定义,如果pointx和y的坐标bMin小于 point 的坐标aMax,则 objectb位于 object 的后面a。

二维空间中的算法数据

二维空间中的算法数据

比较前面示例中的三个元素,我们可以生成下图:

拓扑图

拓扑图

之后,我们需要使用深度优先搜索算法的变体来找到正确的渲染顺序。深度优先搜索允许我们根据可见性顺序访问图节点,从最远的节点开始。这是该算法的一个与库无关的示例:

const sortElements = (elements: Rect[]) => {
    const nodes = elements.map((el) => {
        return {
            el: el,
            behind: [],
            visited: false,
            depth: null,
        };
    });

    for (let i = 0; i < nodes.length; ++i) {
        const a = nodes[i].el;
        const aMax = aBBox.bottomRight();

        for (let j = 0; j < nodes.length; ++j) {
            if (i != j) {
                const b = nodes[j].el;
                const bMin = bBBox.topLeft();
                if (bMin.x < aMax.x && bMin.y < aMax.y) {
                    nodes[i].behind.push(nodes[j]);
                }
            }
        }
    }

    const sortedElements = depthFirstSearch(nodes);
    return sortedElements;
};

const depthFirstSearch = (nodes) => {
    let depth = 0;
    let sortedElements = [];

    const visitNode = (node) => {
        if (!node.visited) {
            node.visited = true;

            for (let i = 0; i < node.behind.length; ++i) {
                if (node.behind[i] == null) {
                    break;
                } else {
                    visitNode(node.behind[i]);
                    delete node.behind[i];
                }
            }

            node.depth = depth++;
            sortedElements.push(node.el);
        }
    };

    for (let i = 0; i < nodes.length; ++i) {
        visitNode(nodes[i]);
    }

    return sortedElements;
};

使用 JointJS 库可以轻松实现此方法 - 在下面的 CodePen 中,每当元素的位置发生更改时,我们都会使用特殊的 JointJS 事件来重新计算元素的 z 索引。如上所述,我们使用z元素模型的特殊属性来指定渲染顺序并在深度优先遍历期间分配它。(请注意,由于等距对象实现的性质,在元素相交的情况下,算法的行为是未定义的。)

等轴测图的 Z 索引计算

以上等轴测图的 Z 索引计算效果显示图代码如下:

<!--css-->
<style>
#paper-container {position:absolute;right:0;top:0;left:0;bottom:0;}
#logo {position:absolute;bottom:20px;right:0;}
label {position:absolute;top:30px;right:30px;font-family:sans-serif;}
label input {vertical-align:text-top;}
</style>
<!--html-->
<div id="paper-container"></div>
<label>
    <span>Isometric Transformation:</span>
    <input type="checkbox" id="isometric-switch" checked />
</label>
<a target="_blank" href="https://www.toymoban.com">
    <img id="logo" src="https://www.toymoban.com/style/defalut/img/logo.png" width="200" height="50"></img>
</a>
<!--JS-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.4.1/backbone-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jointjs@3.7.2/dist/joint.min.js"></script>
<script>
const { dia, shapes, util } = joint;
const GRID_SIZE = 20;
const GRID_COUNT = 12;
// 确保 Z 方向元素可见性的值
const PAPER_Z_OFFSET = GRID_SIZE * 4;
// 等距变换矩阵及其参数
const SCALE = 1;
const ISOMETRIC_SCALE = 0.8602;
const ROTATION_DEGREES = 30;
const transformationMatrix = () => {
    return V.createSVGMatrix()
        .translate(GRID_COUNT * GRID_SIZE * SCALE * ISOMETRIC_SCALE + GRID_SIZE, PAPER_Z_OFFSET + GRID_SIZE)
        .rotate(ROTATION_DEGREES)
        .skewX(-ROTATION_DEGREES)
        .scaleNonUniform(SCALE, SCALE * ISOMETRIC_SCALE);
};
// 这里我们指定元素的标记和
// 标记部分的常量属性
const IsometricPyramid = dia.Element.define(
    'IsometricPyramid',
    {
        attrs: {
            front: {
                strokeWidth: 1,
                stroke: '#333333',
                fillOpacity: '0.8'
            },
            side: {
                strokeWidth: 1,
                stroke: '#333333',
                fillOpacity: '0.8'
            }
        }
    },
    {
        markup: util.svg/* xml */ `
      <polygon @selector="front"></polygon>
      <polygon @selector="side"></polygon>
    `
    }
);
const IsometricRectangularPrism = dia.Element.define(
    'IsometricPyramid',
    {
        attrs: {
            top: {
                strokeWidth: 1,
                stroke: '#333333',
                fillOpacity: '0.8'
            },
            front: {
                strokeWidth: 1,
                stroke: '#333333',
                fillOpacity: '0.8'
            },
            side: {
                strokeWidth: 1,
                stroke: '#333333',
                fillOpacity: '0.8'
            }
        }
    },
    {
        markup: util.svg/* xml */ `
      <polygon @selector="top"></polygon>
      <polygon @selector="front"></polygon>
      <polygon @selector="side"></polygon>
    `
    }
);
// 将尺寸参数转换为标记零件的路径属性
// 这样可以以更灵活的方式创建元素
const createIsometricPyramid = (properties) => {
    const d = {
        x: properties.size.width,
        y: properties.size.height,
        z: properties.isometricHeight
    };
    properties.attrs = properties.attrs || {};

    properties.attrs.front = {
        fill: properties.color,
        points: `${(d.x / 2) - d.z},${(d.y / 2) - d.z} ${d.x},0 ${d.x},${d.y}`,
    };
    properties.attrs.side = {
        fill: properties.color,
        points: `${(d.x / 2) - d.z},${(d.y / 2) - d.z} ${d.x},${d.y} 0,${d.y}`,
    };

    return new IsometricPyramid(properties);
};
const createIsometricRectangularPrism = (properties) => {
    const d = {
        x: properties.size.width,
        y: properties.size.height,
        z: properties.isometricHeight
    };
    properties.attrs = properties.attrs || {};
    properties.attrs.top = {
        fill: properties.color,
        points: `0,0 ${d.x},0 ${d.x},${d.y} 0,${d.y}`,
        transform: `translate(${-d.z},${-d.z})`,
    };
    properties.attrs.side = {
        fill: properties.color,
        points: `0,0 ${d.x},0 ${d.x},${d.z} 0,${d.z}`,
        transform: V.matrixToTransformString(
            new DOMMatrixReadOnly()
                .translate(-d.z, -d.z + d.y)
                .skewX(45)
        )
    };
    properties.attrs.front = {
        fill: properties.color,
        points: `0,0 ${d.z},0 ${d.z},${d.y} 0,${d.y}`,
        transform: V.matrixToTransformString(
            new DOMMatrixReadOnly()
                .translate(-d.z + d.x, -d.z)
                .skewY(45)
        )
    };
    return new IsometricRectangularPrism(properties);
};
// Z-index 计算
const topologicalSort = (nodes) => {
    let depth = 0;

    const visitNode = (node) => {
        if (!node.visited) {
            node.visited = true;

            for (let i = 0; i < node.behind.length; ++i) {
                if (node.behind[i] == null) {
                    break;
                }
                else {
                    visitNode(node.behind[i]);
                    delete node.behind[i];
                }
            }

            node.depth = depth++;
            node.el.set('z', node.depth);
        }
    }

    for (let i = 0; i < nodes.length; ++i)
    {
        visitNode(nodes[i]);
    }
}
const sortElements = (graph) => {
    const elements = graph.getElements();
    const nodes = elements.map(el => {
        return {
            el: el,
            behind: [],
            visited: false
        }
    });
    for (let i = 0; i < nodes.length; ++i) {
        const a = nodes[i].el;
        const aBBox = a.getBBox();
        cellBBoxes[a.id].setAttribute('width', aBBox.width);
        cellBBoxes[a.id].setAttribute('height', aBBox.height);
        cellBBoxes[a.id].setAttribute('x', aBBox.x)
        cellBBoxes[a.id].setAttribute('y', aBBox.y)
        const aMax = aBBox.bottomRight();
        for (let j = 0; j < nodes.length; ++j) {
            if (i != j) {
                const b = nodes[j].el;
                const bBBox = b.getBBox();
                const bMin = bBBox.topLeft();

                if (bMin.x < aMax.x && bMin.y < aMax.y)
                {
                    nodes[i].behind.push(nodes[j]);
                }
            }
        }
    }
    topologicalSort(nodes);
    return nodes;
}
// Paper
const cellNamespace = { ...shapes, IsometricPyramid, IsometricRectangularPrism };
const graph = new dia.Graph({}, { cellNamespace });
const paper = new dia.Paper({
    el: document.getElementById('paper-container'),
    model: graph,
    restrictTranslate: {
        x: 0,
        y: 0,
        width: GRID_SIZE * GRID_COUNT,
        height: GRID_SIZE * GRID_COUNT
    },
    width: '100%',
    height: '100%',
    gridSize: GRID_SIZE,
    async: true,
    autoFreeze: true,
    sorting: dia.Paper.sorting.APPROX,
    cellViewNamespace: cellNamespace
});
// 通过将等距矩阵应用于所有纸张,使纸张等距
// 它包含的 SVG 内容。
paper.matrix(transformationMatrix());
// 将等距元素添加到图表中。
const pyramid = createIsometricPyramid({
    isometricHeight: GRID_SIZE * 4,
    color: '#ff0000',
    size: { width: GRID_SIZE * 2, height: GRID_SIZE * 3 },
    position: { x: GRID_SIZE * 6, y: GRID_SIZE * 6 }
});
const prism = createIsometricRectangularPrism({
    isometricHeight: GRID_SIZE * 2,
    color: '#00ff00',
    size: { width: GRID_SIZE * 2, height: GRID_SIZE * 3 },
    position: { x: GRID_SIZE * 2, y: GRID_SIZE * 2 }
});
const prism2 = createIsometricRectangularPrism({
    isometricHeight: GRID_SIZE * 1,
    color: '#0000ff',
    size: { width: GRID_SIZE * 1, height: GRID_SIZE * 2 },
    position: { x: GRID_SIZE * 8, y: GRID_SIZE * 8 }
});
graph.addCells([pyramid, prism, prism2]);
//绘制网格的函数。
drawGrid(paper);
function drawGrid(paper) {
    const gridData = [];
    const j = GRID_COUNT;
    for (let i = 0; i <= j; i++) {
        gridData.push(`M 0,${i * GRID_SIZE} ${j * GRID_SIZE},${i * GRID_SIZE}`);
        gridData.push(`M ${i * GRID_SIZE},0 ${i * GRID_SIZE},${j * GRID_SIZE}`);
    }
    const gridEl = V('path').attr({
        d: gridData.join(' '),
        fill: 'none',
        stroke: 'lightgray'
    }).node;
    // 当网格附加到纸张的某一层时,它会自动转换
    //通过等距矩阵
    paper.getLayerNode(dia.Paper.Layers.BACK).append(gridEl);
}
const cellBBoxes = {}
graph.getCells().forEach(cell => {
  cellBBoxes[cell.id] = V('rect', {
    fill: '#888888',
    stroke: '#000000',
    'stroke-width': 2
  });
  cellBBoxes[cell.id].appendTo(paper.getLayerNode(dia.Paper.Layers.BACK));
});
graph.on('change:position', () => {
    sortElements(graph);
});
sortElements(graph);
// 添加开关以切换 2d 等距视图以用于演示目的
document
    .getElementById('isometric-switch')
    .addEventListener('change', (evt) => {
        if (evt.target.checked) {
            paper.matrix(transformationMatrix());
        } else {
            paper.matrix(
                V.createSVGMatrix().translate(
                    GRID_SIZE * GRID_COUNT,
                    PAPER_Z_OFFSET + GRID_SIZE
                )
            );
        }
    });
</script>

JointJS 演示

我们创建了一个 JointJS 演示,它结合了所有这些方法和技术,还允许您在 2D 和等距 SVG 标记之间轻松切换。至关重要的是,正如您所看到的,JointJS 的强大功能(它允许我们移动元素、将它们与链接连接以及创建编辑它们的工具等)在等距视图中的工作效果与在 2D 视图中的工作效果一样好。

您可以在此处查看演示。

JointJS 演示

在本文中,我们使用开源 JointJS 库进行说明


文章来源地址https://www.toymoban.com/diary/js/421.html

到此这篇关于使用SVG创建平行投影和等角投影的方法以及创建等轴测图的文章就介绍到这了,更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

原文地址:https://www.toymoban.com/diary/js/421.html

如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请联系站长进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用
今日励志语录:你真的努力过了吗?
上一篇 2023年11月22日 09:55
一次编码,无处不在:深入探讨 .NET MAUI 的跨平台魔力
下一篇 2023年10月18日 14:55

相关文章

  • 用纯C语言实现3D空间中的点坐标转化为屏幕二维点坐标,包含主视图、侧视图、俯视图、正等轴投影

    要实现3D空间中的点坐标转换为屏幕二维点坐标,需要进行透视变换和投影变换。以下是一些基本的思路和示例代码,可以用于实现主视图、侧视图、俯视图、正等轴投影。 1. 主视图投影 主视图投影是指以一个点作为视点,从一个方向观察物体,投影到一个平面上。通常情况

    2024年02月04日
    浏览(58)
  • 图片转Svg格式以及压缩优化方法

    除了文中方法,还可以使用 svgo 工具对svg图片进行压缩:可参考svgo 前端有些时候需要把像png、jpg等格式的图片转为svg格式的矢量图使用,但是直接去一些网站里转换,得到的svg代码往往因为冗余信息实在太多,大得离谱 看着都头疼 以至于cv到QQ里都发不出去 所以查了一波资

    2024年02月05日
    浏览(48)
  • 微信小程序里使用SVG矢量图标方法

    微信小程序里使用SVG矢量图标有2种引入方法: 一、SVG图标转换为BASE64编码 可以在百度搜索’svg在线转BASE64’(可能会有问题,如下) 如下方式 同时还需要添加 background-size: contain 属性使图标能根据元素大小自动缩放 二、使用运程地址引入SVG图标 把SVG图标上传到网站服务器

    2024年02月05日
    浏览(54)
  • .net core 创建WebAPI以及使用EF DBFirst框架使用方法与疑问解答(.net 6)

    EF语法包: 生成实体模型: 修改实体模型: 把生成的实体和上下文都输出到某个文件夹命令 增加JSON格式脚手架: 若想增加某个版本json脚手架,需要加入后缀如: 问题与解决方案: 1、问题: Your startup project \\\'XXX\\\' doesn\\\'t reference Microsoft.EntityFrameworkCore.Design. This package is requi

    2024年02月16日
    浏览(49)
  • 使用html2canvas将整个元素导出为图片,其中包含svg和img,解决img跟svg导出时img或svg(canvg处理)不显示的问题,以及相关优化

    目录 前言 一、准备 二、解决问题 1.将svg跟img转为canvas的方法 2.将base64转换成file文件的方法 3.点击下载使用方法 1).对dom没有处理,需求只是将图片导出即可  2).涉及对dom的拖拽,流程图之类的(需复制dom,在复制的dom上进行处理)  三、效果图如下 查阅很多相关的文章和

    2024年01月20日
    浏览(67)
  • 初学前端-记使用阿里图库SVG图标不显示的解决方法

    使用VUE3+Element-Plus做来制作前端界面,做到左侧菜单栏时遇到了一个困难,添加的SVG图标始终不显示,位置存在,图标的信息也没有问题,但是就是一直显示不出来。  后经多方搜索, 经vue前端项目引入iconfont阿里图标的四种方式_飞歌Fly的博客-CSDN博客的提示在Main.js中导入了

    2024年01月25日
    浏览(70)
  • SVG 在前端的7种使用方法,你还知道哪几种?

    点赞 + 关注 + 收藏 = 学会了 技术一直在演变,在网页中使用 SVG 的方法也层出不穷。每个时期都有对应的最优解。 所以我打算把我知道的 7种 SVG 的使用方法列举出来,有备无患~ 如果你还知道其他方法,可以在评论区补充~ ```svg ``` xml 是浏览器能读取的格式,但如果希望 sv

    2024年02月06日
    浏览(51)
  • 视觉相机模型以及投影原理推导——(单目)

    参考文献:视觉SLAM十四讲、视觉惯性SLAM理论与源码分析、该博客、文中的公式直接引用上面的文章,如有侵权请联系本人删除 投影过程 三维世界中的物体(目标点)P反射光线,通过相机光心,投影到相机的感光平面(物理成像平面/像素成像平面),一个个的光线投影点汇

    2024年02月09日
    浏览(47)
  • 对数螺旋线(等角螺旋线)数学公式推导

    在某些论文或文章里看到对数(等角)螺旋线的公式: 。但是搜索了一下,通常都是一些讲解对数等角螺旋线的性质的结果以及公式结果,并没有详细的公式推导。当然对于大多数人只要知道上述公式就好了,当作一个结论公式使用。 当然也有像我这样的想知道公式怎么推出了

    2023年04月23日
    浏览(47)
  • k8s service的概念以及创建方法

    Service 的功能: Service主要用于提供网络服务,通过Service的定义,能够为客户端应用提供稳定的访问地址(域名或IP地址)和负载均衡功能,以及屏蔽后端Endpoint的变化,是K8s实现微服务的核心资源。 Service 主要解决了什么问题: 引入Service主要是解决Pod的动态变化,通过创建

    2024年03月08日
    浏览(44)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包