Skip to content

搞英语 → 看世界

翻译英文优质信息和名人推特

Menu
  • 首页
  • 作者列表
  • 独立博客
  • 专业媒体
  • 名人推特
  • 邮件列表
  • 关于本站
Menu

在 SVG 中绘制 Truchet 瓷砖

Posted on 2025-12-22

我最近读了Ned Batchelder关于特鲁切瓷砖的文章,这种瓷砖是方形的,铺在平面上可以拼出漂亮的图案。我当时正在尝试为这个网站设计不同的标题,想着或许可以用特鲁切瓷砖。后来我放弃了这个计划,不过画了一些漂亮的图案还是挺有意思的。

最简单的特鲁切瓷砖之一是由两种颜色组成的正方形:

这些可以按规则排列,但随机排列也很好看:

真正吸引我眼球的是克里斯托弗·卡尔森的作品。他创作了一系列“翼状瓷砖”,可以在同一个网格中以多种尺寸排列。一块瓷砖可以与四块颜色反转、带有额外翼状图案的小瓷砖重叠,图案看起来仍然浑然一体。

他定义了十五种瓷砖,这十五种瓷砖是七种不同的图案,然后是各种旋转方式:

这里需要注意的是,每块瓷砖实际上只“拥有”中间的红色方块。铺设时,需要加上向外延伸的“翼片”——正是这些翼片使得较小的瓷砖能够无缝地融入更大的图案中。

以下是卡尔森-特鲁切铺砖法的一个例子:

显示网格线

从概念上讲,我们给计算机一袋方块,让它随机抽取方块,然后观察它把方块放在页面上会发生什么。

在这篇文章中,我将解释如何实现这个功能:用参数化的 SVG 图形填充图块包,然后将它们随机放置在不同大小的位置。我假设您熟悉 SVG 和 JavaScript,但我会在讲解过程中解释相关的几何原理。

将瓷砖装入袋中

虽然卡尔森的这套牌有十五种不同的瓷砖,但它们只由四种基本元素组成,我称之为底座、斜线、楔形和横杠。

第一步是为每个可以重用的原始元素编写 SVG 定义。

每当我进行这类生成艺术创作时,我喜欢用参数化的方式来定义它——编写一个模板,接受可以更改的输入,这样我就可以始终看到输入和结果之间的关系,并且可以稍后调整设置。有很多模板工具;我打算写一些伪代码,而不是专注于某个特定的工具。

对于这些基本图形,有两个变量,我称之为内半径和外半径。外半径是图块角上较大翼片的半径,而内半径是每条边中间前景组件的半径。对于斜线、楔形和横线,内半径是形状与图块边缘相交处宽度的一半。

此图显示了两个变量,以及我在模板中计算的两个变量:

外半径 内半径 瓦片尺寸 边距

以下是这些基本元素的模板:

 <!-- What's the length of one side of the tile, in the red dashed area? tileSize = (innerR + outerR) * 2 --> <!-- How far is the tile offset from the edge of the symbol/path? padding = max(innerR, outerR) --> <symbol id= "base" > <!-- For the background, draw a square that fills the whole tile, then four circles on each of the corners. --> <g class= "background" > <rect x= "" y= "" width= "" height= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> </g> <!-- For the foreground, draw four circles on the middle of each tile edge. --> <g class= "foreground" > <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> </g> </symbol> <!-- Slash: - Move to the top edge, left-hand vertex of the slash - Line to the top edge, right-hand vertex - Smaller arc to left egde, upper vertex - Line down to left edge, lower vertex - Larger arc back to the start --> <path id= "slash" d= "M   l 2 0 a   0 0 0   l 0 2 a   0 0 1  " /> <!-- wedge: - Move to the top edge, left-hand vertex of the slash - Line to the top edge, right-hand vertex - Smaller arc to left egde, upper vertex - Line to centre of the tile - Line back to the start --> <path id= "wedge" d= "M   l 2 0 a   0 0 0   l 0 2 l  0" /> <!-- Bar: horizontal rectangle that spans the tile width and is the same height as a circle on the centre of an edge. --> <rect id= "bar" x= "" y= "" width= "" height= "2" />这<!-- What's the length of one side of the tile, in the red dashed area? tileSize = (innerR + outerR) * 2 --> <!-- How far is the tile offset from the edge of the symbol/path? padding = max(innerR, outerR) --> <symbol id= "base" > <!-- For the background, draw a square that fills the whole tile, then four circles on each of the corners. --> <g class= "background" > <rect x= "" y= "" width= "" height= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> </g> <!-- For the foreground, draw four circles on the middle of each tile edge. --> <g class= "foreground" > <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> <circle cx= "" cy= "" r= "" /> </g> </symbol> <!-- Slash: - Move to the top edge, left-hand vertex of the slash - Line to the top edge, right-hand vertex - Smaller arc to left egde, upper vertex - Line down to left edge, lower vertex - Larger arc back to the start --> <path id= "slash" d= "M   l 2 0 a   0 0 0   l 0 2 a   0 0 1  " /> <!-- wedge: - Move to the top edge, left-hand vertex of the slash - Line to the top edge, right-hand vertex - Smaller arc to left egde, upper vertex - Line to centre of the tile - Line back to the start --> <path id= "wedge" d= "M   l 2 0 a   0 0 0   l 0 2 l  0" /> <!-- Bar: horizontal rectangle that spans the tile width and is the same height as a circle on the centre of an edge. --> <rect id= "bar" x= "" y= "" width= "" height= "2" />

foreground / background类是在 CSS 中定义的,所以我可以选择它们的颜色。

这个模板比渲染后的 SVG 文件更冗长,但我可以看到所有的几何表达式——我觉得这比满是数字的文件更容易阅读。这也方便进行实验——我可以更改输入值,重新渲染模板,并立即看到新的结果。

然后,我可以通过<use>元素引用这些基本形状来组合图块。例如,“T”形图块由一个底座和两个楔形组成:

 <!-- The centre of rotation is the centre of the whole tile, including padding. centreRotation = outerR + innerR --> <symbol id= "carlsonT" > <use href= "#base" /> <use href= "#wedge" class= "foreground" /> <use href= "#wedge" class= "foreground" transform= "rotate(90  )" /> </symbol>之后,我写了一篇类似的文章。 <!-- The centre of rotation is the centre of the whole tile, including padding. centreRotation = outerR + innerR --> <symbol id= "carlsonT" > <use href= "#base" /> <use href= "#wedge" class= "foreground" /> <use href= "#wedge" class= "foreground" transform= "rotate(90  )" /> </symbol>

<symbol>定义所有其他图块,以及交换背景和前景的反转版本。

现在我们有了一袋瓷砖,让我们告诉电脑如何放置它们。

将图块放置在页面上

假设计算机从袋子里抽取了一张方块。为了将它放置在页面上,它需要知道:

  • x 、 y位置和
  • 这一层——应该放置一块全尺寸的瓷砖,还是用一块较小的瓷砖来分割一块较大的瓷砖?

通过这两个属性,它可以计算出其他所有事情——特别是是否反转图块以及将其缩放多大。

步骤很简单:首先获取每一层中所有图块的位置,然后确定其中是否有需要细分的图块。利用这些细分后的图块来定位下一层,并重复此过程。直到下一层为空,或者达到所需的最大层数为止。

以下是该过程的 JavaScript 实现:

 function getTilePositions ({ columns , rows , tileSize , maxLayers , subdivideChance , }) { let tiles = []; // Draw layer 1 of tiles, which is a full-sized tile for // every row and column. for ( i = 0 ; i < columns ; i++ ) { for ( j = 0 ; j < rows ; j++ ) { tiles . push ({ x : i * tileSize , y : j * tileSize , layer : 1 }); } } // Now go through each layer up to maxLayers, and decide which // tiles from the previous layer to subdivide into four smaller tiles. for ( layer = 2 ; layer <= maxLayers ; layer++ ) { let previousLayer = tiles . filter ( t => t . layer === layer - 1 ); // The size of tiles halves with each layer. // On layer 2, the tiles are 1/2 the size of the top layer. // On layer 3, the tiles are 1/4 the size of the top layer. // And so on. let layerTileSize = tileSize * ( 0.5 ** ( layer - 1 )); previousLayer . forEach ( tile => { if ( Math . random () < subdivideChance ) { tiles . push ( { layer , x : tile . x , y : tile . y }, { layer , x : tile . x + layerTileSize , y : tile . y }, { layer , x : tile . x , y : tile . y + layerTileSize }, { layer , x : tile . x + layerTileSize , y : tile . y + layerTileSize }, ) } }) } return tiles ; }一旦我们知道了位置,就可以将它们布局到 SVG 元素中。 function getTilePositions ({ columns , rows , tileSize , maxLayers , subdivideChance , }) { let tiles = []; // Draw layer 1 of tiles, which is a full-sized tile for // every row and column. for ( i = 0 ; i < columns ; i++ ) { for ( j = 0 ; j < rows ; j++ ) { tiles . push ({ x : i * tileSize , y : j * tileSize , layer : 1 }); } } // Now go through each layer up to maxLayers, and decide which // tiles from the previous layer to subdivide into four smaller tiles. for ( layer = 2 ; layer <= maxLayers ; layer++ ) { let previousLayer = tiles . filter ( t => t . layer === layer - 1 ); // The size of tiles halves with each layer. // On layer 2, the tiles are 1/2 the size of the top layer. // On layer 3, the tiles are 1/4 the size of the top layer. // And so on. let layerTileSize = tileSize * ( 0.5 ** ( layer - 1 )); previousLayer . forEach ( tile => { if ( Math . random () < subdivideChance ) { tiles . push ( { layer , x : tile . x , y : tile . y }, { layer , x : tile . x + layerTileSize , y : tile . y }, { layer , x : tile . x , y : tile . y + layerTileSize }, { layer , x : tile . x + layerTileSize , y : tile . y + layerTileSize }, ) } }) } return tiles ; }

我们需要确保将较小的图块缩小以适应区域,并调整其位置——请记住,每个卡尔森图块仅“拥有”中间的红色方块,而两侧的图案则要超出图块区域。以下是代码:

 function drawTruchetTiles ( svg , tileTypes , tilePositions , padding ) { tilePositions . forEach ( c => { // We need to invert the tiles every time we subdivide, so we use // the inverted tiles on even-numbered layers. let tileName = c . layer % 2 === 0 ? tileTypes [ Math . floor ( Math . random () * tileTypes . length )] + " -inverted " : tileTypes [ Math . floor ( Math . random () * tileTypes . length )]; // The full-sized tiles are on layer 1, and every layer below // that halves the tile size. const scale = 0.5 ** ( c . layer - 1 ); // We don't want to draw a tile exactly at (x, y) because that // would include the wings -- we add negative padding to offset. // // At layer 1, adjustment = padding // At layer 2, adjustment = padding * 1/2 // At layer 3, adjustment = padding * 1/2 + padding * 1/4 // const adjustment = -padding * Math . pow ( 0.5 , c . layer - 1 ); svg . innerHTML += ` <use href=" ${ tileName } " x=" ${ c . x / scale } " y=" ${ c . y / scale } " transform="translate( ${ adjustment } ${ adjustment } ) scale( ${ scale } )"/>` ; }); }内边距的设置很棘手,我花了好一会儿才弄明白,但现在一切正常了。正是这些棘手的部分让我喜欢用参数化的方式定义 SVG——它迫使我真正理解其中的原理,而不是不断调整数值直到看起来正确为止。 function drawTruchetTiles ( svg , tileTypes , tilePositions , padding ) { tilePositions . forEach ( c => { // We need to invert the tiles every time we subdivide, so we use // the inverted tiles on even-numbered layers. let tileName = c . layer % 2 === 0 ? tileTypes [ Math . floor ( Math . random () * tileTypes . length )] + " -inverted " : tileTypes [ Math . floor ( Math . random () * tileTypes . length )]; // The full-sized tiles are on layer 1, and every layer below // that halves the tile size. const scale = 0.5 ** ( c . layer - 1 ); // We don't want to draw a tile exactly at (x, y) because that // would include the wings -- we add negative padding to offset. // // At layer 1, adjustment = padding // At layer 2, adjustment = padding * 1/2 // At layer 3, adjustment = padding * 1/2 + padding * 1/4 // const adjustment = -padding * Math . pow ( 0.5 , c . layer - 1 ); svg . innerHTML += ` <use href=" ${ tileName } " x=" ${ c . x / scale } " y=" ${ c . y / scale } " transform="translate( ${ adjustment } ${ adjustment } ) scale( ${ scale } )"/>` ; }); }

演示

下面这张图就是用这段代码绘制卡尔森特鲁切图的:

前景背景

它是由您的浏览器在您加载页面时生成的,由于可能的组合方式太多,因此它是一张独一无二的图片。

如果您想要不同的图片,请重新加载页面,或者告诉计算机绘制一些新的图块。

这些图片让我想起了一种外星语言——就像科幻电影里刻在墙上的那种文字。我可以想象出眼睛、触手、道路,以及一个早已消失的文明留下的警告。

这很有趣,但不太符合我想要的网站风格——我放弃了用特鲁切特瓷砖作为页眉图片的计划。我会把它们留作他用,与此同时,我也玩得很开心。

[如果您的RSS阅读器中此文章的格式显示异常,请访问原文]

原文: https://alexwlchan.net/2025/truchet-tiles/?ref=rss

本站文章系自动翻译,站长会周期检查,如果有不当内容,请点此留言,非常感谢。
  • Abhinav
  • Abigail Pain
  • Adam Fortuna
  • Alberto Gallego
  • Alex Wlchan
  • Alin Panaitiu
  • Anil Dash
  • Answer.AI
  • Arne Bahlo
  • Ben Carlson
  • Ben Kuhn
  • Bert Hubert
  • Big Technology
  • Bits about Money
  • Brandon Skerritt
  • Brian Krebs
  • ByteByteGo
  • Chip Huyen
  • Chips and Cheese
  • Christopher Butler
  • Colin Percival
  • Cool Infographics
  • Dan Sinker
  • David Walsh
  • Dmitry Dolzhenko
  • Dustin Curtis
  • eighty twenty
  • Elad Gil
  • Ellie Huxtable
  • Ethan Dalool
  • Ethan Marcotte
  • Exponential View
  • FAIL Blog
  • Founder Weekly
  • Geoffrey Huntley
  • Geoffrey Litt
  • Greg Mankiw
  • HeardThat Blog
  • Henrique Dias
  • Herman Martinus
  • Hypercritical
  • IEEE Spectrum
  • Investment Talk
  • Jaz
  • Jeff Geerling
  • Jonas Hietala
  • Josh Comeau
  • Lenny Rachitsky
  • Li Haoyi
  • Liz Danzico
  • Lou Plummer
  • Luke Wroblewski
  • Maggie Appleton
  • Matt Baer
  • Matt Stoller
  • Matthias Endler
  • Mert Bulan
  • Mind Matters
  • Mostly metrics
  • Naval Ravikant
  • News Letter
  • NextDraft
  • Non_Interactive
  • Not Boring
  • One Useful Thing
  • Phil Eaton
  • PostHog
  • Product Market Fit
  • Readwise
  • ReedyBear
  • Robert Heaton
  • Rohit Patel
  • Ruben Schade
  • Sage Economics
  • Sam Altman
  • Sam Rose
  • selfh.st
  • Shtetl-Optimized
  • Simon schreibt
  • Slashdot
  • Small Good Things
  • Steph Ango
  • Stephen Wolfram
  • Steve Blank
  • Taylor Troesh
  • Telegram Blog
  • The Macro Compass
  • The Pomp Letter
  • thesephist
  • Thinking Deep & Wide
  • Tim Kellogg
  • Understanding AI
  • Wes Kao
  • 英文媒体
  • 英文推特
  • 英文独立博客
©2025 搞英语 → 看世界 | Design: Newspaperly WordPress Theme