解如何使用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 撰写的一篇精彩文章中找到有关此问题的更多信息。
我们决定使用拓扑排序算法对元素进行排序。该算法由两个步骤组成。首先,我们需要创建一个特殊的图,然后我们需要对该图使用深度优先搜索来找到元素的正确顺序。
作为第一步,我们需要填充初始图 - 对于每个对象,我们需要找到其背后的所有对象。我们可以通过比较它们底边的位置来做到这一点。让我们用图像来说明此步骤 - 例如,我们采用三个元素,它们的位置如下:
我们在第二张图像中标记了每个对象的底侧。使用这些数据,我们将创建一个图形结构,该结构将建模元素之间的拓扑关系。在图像中,您可以看到我们如何定义底部的点 - 我们可以通过比较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 索引计算效果显示图代码如下:
<!--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 库进行说明文章来源:https://www.toymoban.com/diary/js/421.html
文章来源地址https://www.toymoban.com/diary/js/421.html
到此这篇关于使用SVG创建平行投影和等角投影的方法以及创建等轴测图的文章就介绍到这了,更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!