标准的基于 GUI 的图形设计工具仅支持有限的“对齐向导”风格的定位,具有基本的对象分组系统,并实现对齐或分布对象的原始功能。这些工具没有办法记住对象之间的约束和关系,也没有办法定义和重用抽象。我一直不满意现有的设计工具,尤其是用于创建图形和图表的工具,因此我一直在开发一个名为 Basalt 的新系统,它符合我的想法:在关系和抽象方面。
Basalt 以特定领域语言 (DSL) 的形式实现,与 Illustrator 和 Keynote 等基于 GUI 的设计工具截然不同。它也与 D3.js、TikZ 和diagrams等库/语言有很大不同。 Basalt 的核心是基于约束:设计者根据关系指定图形,这些图形编译成约束,使用 SMT 求解器自动求解以产生最终输出。这允许设计师根据关系来指定图纸,例如“这些对象水平分布,它们之间的空间比例为 1:2:3”。约束也是 Basalt 如何支持抽象的一个关键方面,因为约束可以很好地组合。
在过去的几年里,我一直在断断续续地试验这个概念。 Basalt远未完成,但探索已经取得了一些有趣的结果。 该原型非常有用,我用它制作了最新研究论文和演示文稿中的所有图表。
如果你想了解 Basalt 背后的核心思想,请查看哲学部分。 如果想了解我使用 Basalt 设计真实人物的经验,请跳至案例研究部分。 如果想了解如何使用梯度下降来求解图形,请转到梯度下降部分。
Basalt 的编程模型如下。 设计师编写的程序可以生成根据关系描述的图形。 这些关系被编译成约束,然后自动求解。
Basalt 是嵌入在通用编程语言中的 DSL,因此它继承了对功能抽象、类等的支持。 它基于约束的方法是支持很好组合的抽象的关键。
Basalt 的编程模型允许根据对象之间的关系来指定绘图。 用于图形设计的基于 GUI 的标准工具不支持此功能。 它们具有有限的“对齐向导”定位风格,以及基本的对象分组系统和用于对齐或分布对象的基本功能。 他们不根据原始对象和约束对图形进行编码; 相反,像对齐对象这样的动作会强制更新位置,这是底层表示存储的内容。 CAD 工具支持约束,但不适用于图形设计。
考虑具有以下描述的图形。 “有一块浅灰色的帆布。 中间是一个蓝色圆圈,直径是画布宽度的一半。 圆圈内是一个红色正方形。” 图片看起来像这样:
在不使用约束的情况下,在 Basalt 中生成此类图形的代码如下所示:
width = 300
height = 300
bg = Rectangle(0, 0, width, height,
style=Style({Property.fill: RGB(0xe0e0e0)}))
circ = Circle(x=150, y=150, radius=75,
style=Style({Property.stroke: RGB(0x0000ff), Property.fill_opacity: 0}))
rect = Rectangle(x=96.97, y=96.97, width=106.06, height=106.06,
style=Style({Property.stroke: RGB(0xff0000), Property.fill_opacity: 0}))
g = Group([bg, circ, rect])
c = Canvas(g, width=width, height=height)
设计师必须弄清楚一切都在哪里,手动计算锚点和大小。 相反,有了约束,设计师可以写下形状之间的关系,该工具将解决这个问题:
width = 300
height = 300
bg = Rectangle(0, 0, width, height, style=Style({Property.fill: RGB(0xe0e0e0)}))
circ = Circle(style=Style({Property.stroke: RGB(0x0000ff), Property.fill_opacity: 0}))
rect = Rectangle(style=Style({Property.stroke: RGB(0xff0000), Property.fill_opacity: 0}))
# The Group constructor takes two arguments: the first is a list of objects,
# and the second lists additional constraints, equations that relate the
# objects to one another
g = Group([bg, circ, rect], [
# circle is centered
circ.bounds.center == bg.bounds.center,
# circle diameter is 1/2 of canvas width
2*circ.radius == width/2,
# rectangle is centered on circle
rect.bounds.center == circ.bounds.center,
# rectangle is a square
rect.width == rect.height,
# rectangle is circumscribed
rect.width == circ.radius*2**0.5
])
c = Canvas(g, width=width, height=height)
每个图元都知道如何根据其内部属性计算自己的边界:例如,圆的边界是 (x-r, y-r)(x−r,y−r) 到 (x+r, y+r)(x+ r,y+r),并且一个组的界限是根据单个元素界限的最小值/最大值来定义的。允许属性和边界是符号表达式:例如在上面的代码中,圆没有给出具体的圆心或半径,因此这些属性各自自动初始化为一个新的 Variable(),然后边界取决于这些变量。当约束求解器运行时,它们只会被分配一个具体的值。
Basalt 基于约束的方法允许设计师根据关系进行思考,并在图纸本身的规范中表达这些关系,而不是让它们隐含并由设计师手动解决。
上图可以在 Illustrator 中绘制,但可能需要拿出铅笔和纸来计算物体的位置。手动解决设计人员考虑但未在工具中编码的隐式约束(这是设计人员目前对现有程序所做的)是痛苦的且不可扩展的。
如果图形设计稍微改变一下,例如圆形要刻在矩形中怎么办?使用 Illustrator,需要手动重新计算所有位置;使用 Basalt,只需一行代码:
- # rectangle is circumscribed
- rect.width == circ.radius*2**0.5
+ # circle is inscribed
+ rect.width == circ.radius*2
对于一个简单的图形,手动进行这样的更改可能是可行的,但是如果图形有数百个对象怎么办? 使用 Illustrator,根据设计师头脑中的限制手动将所有对象精确定位到它们需要去的位置会失控。 在 Basalt 中,约束求解器完成了确定对象精确位置的艰巨工作,并且扩展性很好(例如,这个图形有数百个对象)。
约束导致支持抽象的自然方式,这是设计复杂图形所必需的另一个关键方面。 子组件可以根据它们的部件和内部约束来指定。 当这些组件被实例化用于顶级图(或更高级别的组件)时,约束可以简单地合并在一起。
假设我们想要一个外接正方形的抽象,然后有四个,排列成 2x2 网格,填满画布。 首先,我们可以定义抽象,一个由圆形和矩形组成的组,具有一些内部约束:
def circumscribed_square():
circ = Circle(style=Style({
Property.stroke: RGB(0x0000ff),
Property.fill_opacity: 0
}))
rect = Rectangle(style=Style({
Property.stroke: RGB(0xff0000),
Property.fill_opacity: 0
}))
return Group([circ, rect], [
# rectangle is centered on circle
rect.bounds.center == circ.bounds.center,
# rectangle is a square
rect.width == rect.height,
# rectangle is circumscribed
rect.width == circ.radius*2**0.5
])
然后,我们可以多次实例化它,并添加它们排列在 2x2 网格中且大小相同的约束:
c1 = circumscribed_square()
c2 = circumscribed_square()
c3 = circumscribed_square()
c4 = circumscribed_square()
g = Group([c1, c2, c3, c4], [
# arranged like
# c1 c2
# c3 c4
c1.bounds.right_edge == c2.bounds.left_edge,
c3.bounds.right_edge == c4.bounds.left_edge,
c1.bounds.bottom_edge == c3.bounds.top_edge,
# and all the same size
c1.bounds.width == c2.bounds.width,
c2.bounds.width == c3.bounds.width,
c3.bounds.width == c4.bounds.width,
])
最后,我们可以表达图形填充画布的约束:
width = 300
height = 300
bg = Rectangle(0, 0, width, height, style=Style({Property.fill: RGB(0xe0e0e0)}))
top = Group([bg, g], [
# figure is centered
g.bounds.center == bg.bounds.center,
# and fills the canvas
g.bounds.height == bg.bounds.height
])
c = Canvas(top, width=width, height=height)
渲染时,此代码生成下图:
约束不一定是最终用户应该直接编写的内容,但约束构成了一种很好的装配语言:根据 Basalt 的约束可以很容易地表达更高层次的几何概念。 例如,表示一组对象是顶部对齐的就这么简单:
def aligned_top(elements):
top = Variable() # some unknown value
return Set([i.bounds.top == top for i in elements])
其他概念,如水平或垂直分布的对象、相同的宽度或高度,或插入另一个对象内,也可以编译为约束。 Basalt 提供了一些更高层次的几何图元,用户可以定义自己的。
这是我为一篇论文制作的图,使用Basalt 重新创建:
该图显示了许多图像,由边框颜色给出的这些图像的分类。 这个图形的 Basalt 代码定义了两个抽象,它们组成了这个图形:
def bordered_image(source: str, border_color, border_radius):
image = Image(source)
border = Rectangle(style=Style({Property.fill: border_color}))
return Group([border, image], [
inset(image, border, border_radius, border_radius)
])
def grid(elements: List[List[Element]], spacing):
rows = [Group(i) for i in elements]
return Group(rows, [
# all elements are the same size
same_size([i for row in rows for i in row.elements]),
# elements in rows are distributed horizontally
Set([distributed_horizontally(row, spacing) for row in rows]),
# and aligned vertically
Set([aligned_middle(row) for row in rows]),
# rows are distributed vertically
Set([distributed_vertically(rows, spacing)]),
# and aligned horizontally
aligned_center(rows)
])
这个图非常简单,可以用 Illustrator 等传统工具绘制它,但以编程方式绘制它的好处不仅仅是避免在基于 GUI 的工具中手动布置图形的烦恼。 该论文有 5 个这种风格的图形,具有不同的图像和大小:使用代码允许我们对这些图形进行参数化,并只完成设计图形的工作一次。 此外,这些数字本来是要写在纸上的,这是一种相当受限的格式:例如,我们的数字的宽度是固定的。 我们需要确定图形参数,例如网格尺寸、边框大小和图像大小,这将使图形具有可读性,并且让代码生成图形可以轻松探索此参数空间。
论文中的原始图形实际上是在没有Basalt的情况下制作的,因为当时不存在Basalt。 相反,我编写了直接绘制输出图像像素的 Python 代码,但 Basalt 代码要好得多。
我和我的兄弟 Ashay 为 Robust ML 设计了徽标。 从盾牌和神经网络的一般概念开始,Ashay 在纸上画了几个草图:
接下来,我在 Illustrator 中绘制了徽标草图。 即使在我们有了徽标的基本想法之后,还是需要进行大量迭代才能弄清楚细节,包括选择神经网络中的层数、每层中的节点数以及节点之间的间距给定层。 某些更改很容易,例如使用 Illustrator 的重新着色图稿功能调整颜色。 其他变化非常痛苦; 例如,添加一个节点需要大量的手工劳动,因为它需要移动节点和线以及创建和定位一个新节点和许多新线。 累积起来,在标志的几十次迭代中,我花了几个小时在 Illustrator 中移动形状。
从那以后,我用 Basalt 重新制作了标志,在实时预览工具的帮助下探索参数空间要容易得多。
这是最终输出的样子:
这是在 TikZ 中制作的图,来自最近一篇论文的草稿:
这是上面 TikZ 图的代码。 它使用大量硬编码位置,使用像 \draw [boxed] (-1,-2.5) rectangle (16.5,4) 这样的命令。 使用 TikZ 很难使图形看起来特别漂亮。 对于论文的已发布版本,我使用 Basalt 设计了图形,在此过程中还稍微更改了样式:
该图定义并使用了许多建立在 Basalt 图元之上的新抽象,包括:
描述这些抽象概念一次,然后多次使用它们会带来愉快的图形设计体验。 比如上图中Wiring定义了一次,实例化了11次。 当然,整个图表都是使用关系指定的。 诸如"Kernel domain"文本位于其区域中心之类概念很容易表达。 该代码使用了许多几何图元,表达了组件的输入/输出沿边缘均匀分布等概念。
这是同一篇论文中的另一幅图:
论文中的图表共用抽象以保持一致的外观; 这个图使用了上图中的 Component和 Wiring抽象。 基于约束的设计特别有助于设计该图的某些方面,像底部箭头的间距这些。 例如,让箭头沿框的整个宽度均匀分布看起来很糟糕; 这很容易测试,只需要对 I/O 箭头抽象做一点小改动:
- distributed_horizontally(arrows.elements, periph_io_spacing),
- arrows.bounds.center.x == periph.bounds.center.x
+ distributed_horizontally(arrows.elements),
+ arrows.bounds.left == periph.bounds.left,
+ arrows.bounds.right == periph.bounds.right
基于约束的设计,连同 Basalt 的实时预览工具,可以轻松选择其他参数,使它们看起来适合最终图形。
在代码中实现此图还可以轻松完成诸如匹配图和论文中文本之间的字体大小之类的操作。 在外部工具中设计图形时,图形通常会在事后缩放以适合纸张,但这会影响有效字体大小。 使用 Basalt,可以很容易地设计整体图形,然后适当地调整它的大小,以便在不重新缩放的情况下将其包含在论文中,最后调整图形参数,使其可以使用与论文相同的字体大小。
我需要制作架构图的简化版本以供演示。此外,我需要在多张幻灯片上制作动画以突出显示和解释不同的方面。
我开始在 Keynote 中实现演示文稿和图形,但是在 Keynote 中设计图形有点痛苦,因为对象重叠,而且构建阶段的细节也不同。我尝试在一张幻灯片上做所有事情,但我遇到了 Keynote 动画系统的限制(例如,不可能让一个对象出现,然后消失,然后再次出现)。我尝试复制一些对象来解决这个问题,但很快就失控了,即使使用 Keynote 的对象列表也是如此。我尝试将构建拆分为多张幻灯片,但随后进行全局编辑变得很烦人。
最后,我改用 Basalt 作为图形(并将演示文稿切换为 Beamer)。设计一个细节因构建阶段而异的图形很简单:
基于构建阶段更改的方面利用了 BUILD 变量,例如 reset_color = red if BUILD == 3 else black。 此 BUILD 变量是通过环境变量 BUILD = os.environ[‘BUILD’] 设置的,因此可以通过设置例如 BUILD=2 并渲染图形。
同一个演示文稿还有另一个由一系列幻灯片组成的图:
它使用与上图相同的构建变量方法来编码基于构建阶段发生变化的方面,例如图中底部的分隔符:
labels = []
if BUILD >= 1: labels.append('Agent A runs')
if BUILD >= 2: labels.append('Deterministic start')
if BUILD >= 3: labels.append('Agent B runs')
if BUILD == 4:
labels = ['Deterministic start']
delim = LabeledDelimiters(labels)
为了渲染构建的不同阶段,图形使用不同的变量设置进行渲染,例如 建造=3。
该图旨在匹配上图中设置的相同视觉语言。 这很容易通过与上图共享抽象来实现(此处使用略有不同的参数实例化,例如较低的线数)。
同样,Basalt 的实时预览工具有助于选择图形参数,这样图形看起来不错,字体大小与演示文稿中的其他图形匹配。
我想尝试从我没有写过的论文中重新创建一个复杂的图形。 原件是由该论文的一位作者在 Inkscape 中制作的。 Basalt 版本的目标不是创建像素完美的娱乐,而是使用 Basalt 构建抽象的方法,看看它如何扩展到复杂的图形。 这是一个并排比较,显示 Basalt 能够复制该图:
Inkscape 图是一大堆手动定位的对象,大概是通过一堆复制粘贴制作的。 另一方面,Basalt 图定义并使用了许多抽象,所有抽象都实现为构建在 Basalt 基元之上的类:
这个数字需要一些努力才能实现。 Basalt 的实时预览工具在处理这个图形时非常有用:更改代码后立即看到图形的更新对于构建这个复杂的图形至关重要。 滑块也有助于使布局与原始布局相匹配。 在保持其总体结构的同时调整参数并查看图形自身重绘是非常简洁的:
Basalt 允许设计人员根据关系和约束来指定图表,这些关系和约束归结为基于实变量的方程式。 Basalt 对这些方程式没有太多限制,这为用户提供了很大的灵活性,但它使底层实现更具挑战性,因为它必须自动求解这些方程式。 Basalt方程可以有诸如共轭和反共轭、最小/最大项和变量的乘积/商之类的东西,所以在一般情况下,不可能将方程转换成一些很好的形式,比如计算效率高的线性规划问题。 Basalt方程符合无量词非线性实数算术 (QFNRA) 的逻辑,因此至少可满足性是可判定的,但求解不一定会很快。
目前,Basalt 支持几种不同的方程求解策略。
当前默认使用 Z3 定理证明器,这是一个强大的 SMT 求解器,支持 QFNRA 等。 将来自 Basalt 的约束编码到 Z3 查询中很简单,在实践中,Z3 工作得很好。
Basalt 支持将约束子集编译为混合整数线性规划 (MILP)问题,这些约束使用线性实数算术以及一些操作(如最小/最大),然后使用 MILP 求解器求解,目前使用的是 Google 的OR-Tools 的CBC求解器。 Basalt 的 MILP 后端严格来说不如 Z3 后端通用,即使在它支持的查询上它似乎也比 Z3 慢,但实现起来很有趣。
到目前为止,最没用但最有趣的方程求解器是基于梯度下降的。 从 Basalt 约束到可微损失函数有一个直接的转换,这个转换是合理的。 这意味着当且仅当原始约束可满足时,损失函数具有全局最小值0,并且当损失函数确实具有全局最小值 0 时,每个全局最小值都以直接的方式对应于原始约束的令人满意的分配变量。
当然,这并没有说明这个损失函数的特性,也没有说明它是否适合通过梯度下降进行优化。 但我还是继续实施了它,使用 TensorFlow 来处理计算导数的任务。
与其他方法不同,梯度下降迭代地计算解决方案,因此可以动画化图形的求解,结果非常有趣:
有时,用户生成的图形中的约束集有多个解;换句话说,这个数字没有完全约束。根据我的经验,这几乎总是表示图形代码中存在错误,因为用户不太可能有两个外观不同的图形,其中任何一个图形都可以接受。
Basalt 可以确定这种情况何时发生,因为它可以直接编码为 SMT 查询。如果原始约束集是 CC,它有一个解 M=(x 1 =v1 ∧x2 =v2 ∧⋯xn =vn) ,那么我们可以询问 SMT 求解器 C∧¬M 是否有解。如果新公式不可满足,则原解是唯一的,图形是完全约束的。在新公式可满足的情况下,SMT 求解器返回另一个有效解的具体示例。比较这两个解决方案可以帮助用户弄清楚图形约束不足的原因。
Basalt还有很多工作要做。
现在,它是 Python 中的嵌入式 DSL,而且这种语言有点笨拙。 Racket,一种用于构建编程语言的编程语言,可能是一个更好的平台。 Tej Chajed 和我目前正致力于构建一个更复杂的使用 Racket DSL的Basalt 版本。
我有兴趣探索的另一件事是拥有 Basalt 图形的可视化编辑器,这是 Basalt 方法和现有的基于 GUI 的工具之间的一种混合体,因为能够四处拖动对象通常很方便。 这可能涉及使用类似于 CAD 工具中使用的方法,或者可能更奇特的方法,例如 g9.js 中使用的技术,在代码及其可视化表示之间建立双向绑定。
从 1963年Ivan Sutherland 的 Sketchpad 开始,基于约束的设计领域进行了长期的研究。据我所知,大多数使用这些想法的工具都是基于 GUI 的 CAD 程序,而不是实用的图形设计工具。
Basalt 是一种基于约束设计图形的方法。 Basalt 的模型允许设计人员根据对象之间的关系进行思考,并且可以轻松构建和重用抽象。
Basalt仍在进行中。 如果你有兴趣了解更新,请单击此处。
原文链接:基于约束的图形设计 — BimAnt