1735653461
2024-12-11 09:02:00
このホリデーシーズンには、カスタムのジグソーパズルのギフトを作りたいと思っていました。計画:
- ジグソーパズルジェネレーターを作成します。
- レーザーカッターを使用して切り抜きます。 (とても楽しいです!)
パズルをランダムに生成することは、初心者やいじくり回す人にとっては良い問題なので、私が思いついたアプローチを書きたいと思いました。同様のことを試みた場合、特に別の解決策を思いついた場合は、ぜひご意見をいただきたいと思います。
私が使用した エルムなぜなら、SVG を生成する優れた方法があり、たまたまそれが私が最もよく知っているフロントエンド言語だからです。
イントロ
補足: この投稿は予想よりも長くなってしまいました。コードに直接スキップしたい場合は、次の場所にあります。 https://github.com/2mol/jigsaw-tutorial/。
実際には 2 つの試みを行いました。最初の試みは、 ボロノイ テッセレーション そしてそれを多用する elm-geometry。このアプローチにはいくつかの微妙な問題があり、私は振り出しに戻って 2 回目の試みをすることになりました。
このアプローチについては詳しくは説明しませんが、代わりに、2 回目の試みに基づいて、完全に機能するソリューションについて説明します。ただし、興味がある場合は、私の乱雑なオリジナルのコード リポジトリをご覧ください。 https://github.com/2mol/elm-jigsaw。
問題を分解した方法は次のとおりです。
概要は以上です。ただし、それを機能させるには、いくつかの小さな問題を解決する必要があることがわかりました。コードを見てみることができます エリー または このチュートリアルのコード リポジトリ。
見る 改善の可能性 パズルをより良くする方法、またはツール全体を磨き上げる方法に関するいくつかのアイデアについては、以下を参照してください。
私たちが達成しようとしていることは次のとおりです。
Elm プロジェクトのセットアップ
あなたが持っていると仮定します Elm コンパイラがインストールされている。プロジェクトを初期化するために必要なものは次のとおりです。
mkdir jigsaw-tutorial
cd jigsaw-tutorial
elm init
elm install elm/random
elm install elm/svg
elm install elm-community/list-extra
今すぐ作成してください src/Main.elm、 打ち上げ elm reactor コマンドラインで開きます http://localhost:8000/src/
スケルトンをあげましょう Main.elm。コメントアウトされたインポートはすぐに必要になるため、すでにコメントを解除しておいてください。
module Main exposing (main)
-- import Dict exposing (Dict)
-- import List.Extra as List
-- import Random
import Svg exposing (Svg)
import Svg.Attributes exposing (..)
main : Svg msg
main =
Svg.svg
[ width "800"
, height "600"
, viewBox "0 0 800 600"
]
[ Svg.rect
[ x "0", y "0"
, width "100", height "100"
]
[]
]
だからもう私はそうではない 素晴らしい 2つのことを愛する:
- 2 か所で寸法を繰り返す必要がありますが、それらは文字列です。
- 描画ウィンドウの位置を視覚的に知らせてくれる、優れたキャンバス背景がありません。
私は、寸法をパラメータ化し、市松模様の背景パターンを描画する便利な関数を使用してこれを修正しました。このコードはそれほど興味深いものではありませんが、見てみると良いでしょう。 ここ、または Ellie で対話的に使用します ここ。次のようになります。

すばらしい!それを邪魔にならないようにして、実際の問題を解決し始めます。
グリッドを生成する
後知恵として、いくつかのことを抽象化します。いくつかの基本的なタイプと、パズルのパラメーターを保持するトップレベルのレコードを定義しましょう。
module Main exposing (main)
-- [...]
puzzle =
{ piecesX = 12
, piecesY = 6
-- , gridPerturb = 6
-- , seed = Random.initialSeed 666
-- , draftMode = False
, pixelsPerCell = 50
}
type alias Point =
{ x : Int
, y : Int
}
-- type alias Edge =
-- { start : Point
-- , end : Point
-- }
繰り返しますが、コメントアウトされた部分は、すぐに何が必要かを知るためのものです。
次に、正方形のピースのグリッドを作成する必要があります。いくつかの考え:
- グリッドはコーナーポイントの 2 次元リストですが、 また エッジのリスト。どちらを生成するかは、それを使って何をしたいかによって異なります。
- 単位はピクセルになります。
- ピースの座標をピースの番号付けとは別にしておきたい場合があります。私はよくこの 2 つを混同していました。
- エッジで作業するときにこの番号付けを引き継ぐには少し注意が必要ですが、後でパスを並べ替えるときに役立ちます。
これを念頭に置いて、私はグリッドを次のように説明することにしました。 Dict ポイントの。キーは私のキーに対応するタプルです (x, y) ピースには番号が付けられており、ポイント自体にはその座標が含まれています。
ちょっとしたプログラミング 101 を使って、グリッドのコーナーポイントの辞書を提供する関数を次に示します。
rectangularGrid : Int -> Int -> Dict ( Int, Int ) Point
rectangularGrid nx ny =
let
indicesX =
List.range 0 nx
indicesY =
List.range 0 ny
indices =
List.lift2 Tuple.pair indicesX indicesY
in
indices
|> List.map
(( ix, iy ) ->
( ( ix, iy )
, { x = ix * puzzle.pixelsPerCell
, y = iy * puzzle.pixelsPerCell
}
)
)
|> Dict.fromList
これが描けないとあまり役に立ちません。それでは、いくつかの点を描いてみましょう
main : Svg msg
main =
let
grid =
rectangularGrid puzzle.piecesX puzzle.piecesY
markers =
Dict.values grid
|> List.map drawMarker
in
canvas params.width params.height
markers
-- DRAWING FUNCTIONS
drawMarker : Point -> Svg msg
drawMarker { x, y } =
Svg.circle
[ cx String.fromInt x
, cy String.fromInt y
, r "2"
, stroke "#666"
, fillOpacity "0"
]
[]
チェックポイント: できます エリーのこれを見てください、 または この時点でのコード リポジトリを参照する。

これは良いことのように思えますが、残念ながらまだやるべきことがいくつかあります。私たちは最終的にはコーナーではなくエッジを扱いたいと考えています。したがって、課題は、次の型シグネチャを持つ関数を見つけることです。
type alias Point =
{ x : Int
, y : Int
}
type alias Edge =
{ start : Point
, end : Point
}
calcEdges : Dict ( Int, Int ) Point -> List Edge
calcEdges = ???
これは私がプログラミングの演習として解決するのが好きな類のものなので、それをやりたいなら、今がその時です。
解決策の 1 つは次のとおりです。
calcEdges : Dict ( Int, Int ) Point -> List Edge
calcEdges grid =
let
maybeConnect indices point =
Dict.get indices grid
|> Maybe.map (point2 -> { start = point, end = point2 })
horizontals =
grid
|> Dict.map (( ix, iy ) point -> maybeConnect ( ix + 1, iy ) point)
|> Dict.toList
|> List.sortBy (( ( _, iy ), _ ) -> iy)
|> List.map Tuple.second
|> List.filterMap identity
verticals =
grid
|> Dict.map (( ix, iy ) point -> maybeConnect ( ix, iy + 1 ) point)
|> Dict.toList
|> List.sortBy (( ( ix, _ ), _ ) -> ix)
|> List.map Tuple.second
|> List.filterMap identity
in
horizontals ++ verticals
必要以上に少し複雑になるかもしれませんが、並べ替え順序を制御できるようにしたいと思っていました。
もう一度、これを描いてみましょう。
main : Svg msg
main =
let
grid =
rectangularGrid puzzle.piecesX puzzle.piecesY
markers =
Dict.values grid
|> List.map drawMarker
edges =
calcEdges grid
in
canvas params.width params.height
[ Svg.g [] markers
, Svg.g [] List.map drawEdge edges
]
-- DRAWING FUNCTIONS
drawEdge : Edge -> Svg msg
drawEdge { start, end } =
Svg.line
[ x1 String.fromInt start.x
, y1 String.fromInt start.y
, x2 String.fromInt end.x
, y2 String.fromInt end.y
, strokeWidth "1"
, stroke "#c66"
]
[]
進捗!これは次のようになります。

チェックポイントはこちら エリー そして コードリポジトリ このステップのために。
グリッドを小刻みに動かす
このグリッドを退屈しないようにしましょう。私たちが選択したセットアップの利点は、グリッドを変換するだけの関数を使用できることです。 Dict 別のグリッドに Dict。新しいコードは次のとおりです。
module Main exposing (main)
-- ...
import Random
puzzle =
{ draftMode = True
, piecesX = 12
, piecesY = 6
, pixelsPerCell = 50
, seed = Random.initialSeed 666
, gridPerturb = 6
}
perturbGrid : Dict ( Int, Int ) Point -> Dict ( Int, Int ) Point
perturbGrid grid =
let
pert =
puzzle.gridPerturb
randomPair =
Random.pair
(Random.int -pert pert)
(Random.int -pert pert)
randomPairListGen =
Random.list (Dict.size grid) randomPair
( randomPairList, _ ) =
Random.step randomPairListGen puzzle.seed
in
Dict.values grid
|> List.map2 (( rx, ry ) point -> { x = point.x + rx, y = point.y + ry }) randomPairList
-- optional: keep borders straight
|> List.map snapToBorder
|> List.map2 Tuple.pair (Dict.keys grid)
|> Dict.fromList
snapToBorder : Point -> Point
snapToBorder { x, y } =
{ x = snapToBorder_ puzzle.gridPerturb params.width x
, y = snapToBorder_ puzzle.gridPerturb params.height y
}
snapToBorder_ : Int -> Int -> Int -> Int
snapToBorder_ howClose maxCoord coord =
if coord - howClose 0 then
0
else if coord + howClose >= maxCoord then
maxCoord
else
coord
繰り返しますが、これはほとんど単純なコードですが、使用するのはかなり困難でした Random 正しい方法。物事のリストに同じシードを使用して、どこでも同じ摂動が発生するという罠に陥りがちです。
上記の残りの部分は、外側のエッジを長方形の境界線の形状に戻すためのありふれたロジックで構成されています。より興味深いパズルを取得するには、これをスキップすることもできます。

パズルのエッジ形状
まだ大したことはないようですが、次のステップですべてがうまくいきます。
このアイデアは、各内側のエッジを典型的なパズルの舌の形状に置き換えて、連動するパーツを作成することです。私たちは基本的にどこでもこれを望んでいます:

ベジェ曲線についての基本的な直観を持っておくと役立ちます。構文自体については、 SVGパスの説明 かなり。
基本的に、3 次スプライン (2 つの点と 2 つの制御点を持つ曲線) 用の構文があり、さらにそれらの束をチェーンするための特別な構文もあります。次のようなものを生成しようとしています。
svg>
path d="M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80"/>
svg>
上記の座標は、曲線上の点とそれぞれの制御点のものです。文字は、どれがどれであるかをタグ付けする方法ですが、制御点の 1 つも省略されます。これは、制御点が前の制御点と対称であるためです。
非常に混乱しやすいです!たとえば、エンドポイントは、ポイント自体の前にコントロール ポイントの座標が指定される唯一の場所です… どの座標ペアが何を意味するかを追跡するために、レコード タイプを定義します。
以下にその描画機能を示します。
type alias Curve3 =
{ start : Point
, startControl : Point
, middle : Point
, middleControl : Point
, endControl : Point
, end : Point
}
drawCurve3 : Curve3 -> Svg msg
drawCurve3 curve =
let
m =
[ "M"
, String.fromInt curve.start.x
, String.fromInt curve.start.y
]
c =
[ "C"
, String.fromInt curve.startControl.x
, String.fromInt curve.startControl.y
, String.fromInt curve.middleControl.x
, String.fromInt curve.middleControl.y
, String.fromInt curve.middle.x
, String.fromInt curve.middle.y
]
s =
[ "S"
, String.fromInt curve.endControl.x
, String.fromInt curve.endControl.y
, String.fromInt curve.end.x
, String.fromInt curve.end.y
]
pathString =
[ m, c, s ]
|> List.map (String.join " ")
|> String.join ""
in
Svg.path
[ stroke "black"
, fill "none"
, d pathString
]
[]
ここで、最後に解決すべき楽しい問題が 1 つあります。エッジを与えて、そこに舌の形状を合わせます。ピースをどの方向に反転するかを決定するパラメータも必要です。ブール値を使用しましたが、次のように適切な型を定義すると良いでしょう
type FlipDirection = OneWay | TheOtherway
これらをランダムに生成することは、次のリストを生成するのとまったく同じくらい簡単です。 Bool、だから私の怠惰にブーイングしてください。
わかりました、これを実行してください:
makeTongue : Bool -> Edge -> Curve3
makeTongue = ???
試してみましたか?
これが私のやり方です。これは基本的に、エッジと曲線点の間の線形補間です。これは確かにあるものです たくさん より良い関係 elm-geometryなぜなら、基本形状を定義するだけで、それを適切な方法で回転、移動、拡大縮小できるからです。
makeTongue : Bool -> Edge -> Curve3
makeTongue flip { start, end } =
let
vEdge =
{ x = end.x - start.x
, y = end.y - start.y
}
vPerp =
if start.y /= end.y then
{ x = 1
, y = toFloat -vEdge.x / toFloat vEdge.y |> round
}
else
--if start.x /= end.x
{ x = toFloat -vEdge.y / toFloat vEdge.x |> round
, y = 1
}
flipMult =
if flip then
-1
else
1
vPerpN =
{ x = flipMult * toFloat vPerp.x / norm vPerp |> round
, y = flipMult * toFloat vPerp.y / norm vPerp |> round
}
middleScale =
toFloat puzzle.pixelsPerCell * 0.18 |> round
scaleV h vect =
{ x = toFloat vect.x * h |> round
, y = toFloat vect.y * h |> round
}
scale h n =
toFloat n * h |> round
middle =
{ x = (vPerpN.x * middleScale) + scale 0.5 (start.x + end.x)
, y = (vPerpN.y * middleScale) + scale 0.5 (start.y + end.y)
}
in
{ start = Point start.x start.y
, startControl = Point (start.x + scale 0.8 vEdge.x) (start.y + scale 0.8 vEdge.y)
, middleControl = Point (middle.x - scale 0.4 vEdge.x) (middle.y - scale 0.4 vEdge.y)
, middle = Point middle.x middle.y
, endControl = Point (end.x - scale 0.8 vEdge.x) (end.y - scale 0.8 vEdge.y)
, end = Point end.x end.y
}
-- The norm (length) of a vector.
norm : Point -> Float
norm vect =
(vect.x ^ 2 + vect.y ^ 2)
|> toFloat
|> sqrt
上記には確かに魔法の数字がたくさんありますが、それを終わらせなければならなかったので、より良い抽象化を作成する時間がありませんでした。でも、ちゃんと動いているので、あまり気にしていません。
全部つなげてみましょう!追加のコードの一部が何を行うかは、ご自身で理解していただくことにします。
main : Svg msg
main =
let
cnvs =
canvas params.width params.height
grid =
rectangularGrid puzzle.piecesX puzzle.piecesY
|> perturbGrid
markers =
Dict.values grid
|> List.map drawMarker
isOnBorder edge =
False
|| (edge.start.x == 0 && edge.end.x == 0)
|| (edge.start.y == 0 && edge.end.y == 0)
|| (edge.start.x == params.width && edge.end.x == params.width)
|| (edge.start.y == params.height && edge.end.y == params.height)
edges =
calcEdges grid
|> List.filter (not isOnBorder)
( flips, _ ) =
Random.uniform True [ True, False ]
|> Random.list (List.length edges)
|> (l -> Random.step l puzzle.seed)
tongues =
List.map2 makeTongue flips edges
border =
Svg.rect
[ x "0"
, y "0"
, width (String.fromInt params.width)
, height (String.fromInt params.height)
, fillOpacity "0"
, stroke "black"
]
[]
in
cnvs if puzzle.draftMode then
[ Svg.g [] List.map drawCurve3 tongues
, border
, Svg.g [] markers
, Svg.g [] List.map drawEdge edges
]
else
[ Svg.g [] List.map drawCurve3 tongues
, border
]
最後にもう一度、すべてを一緒に見ることができます エリー、または、をご覧ください。 最終コード。
結果を見てみましょう:

スイッチ draftMode に Falseそして、最後のパズルの栄光を見ることができます。

SVGを抽出する
忘れそうになっていた重要なことが 1 つあります。素晴らしい SVG コードが生成されましたが、それを取り出すにはどうすればよいでしょうか? DOM 文字列を表示する時間がもうなかったので、できる限り簡単な方法で実行しました。生成された SVG をサイトのソース コードからコピーして貼り付けました。

…

これはとてもばかげていますが、このような迅速でハックな解決策はとても嬉しいです。これらを 100 個エクスポートしたい場合は、svg 文字列を簡単に表示したり、それをクリップボードにコピーするボタンに接続したりすることもできます。ここから生成された svg ファイルを elm にダウンロードさせる方法をご存知の場合は、お知らせください。
これを .svg ファイルとして保存するか、ベクター グラフィック エディターの機能/プラグインを使用します。私が使用した SVG挿入 たまたまSketchを使っているからです。
結論
このチュートリアルでは、最終的な幸せなパスのみを示します。この問題を表現して解決するための優れた方法を見つけるには少し手間がかかりました。一見単純な部分で行き詰まってもイライラしないでください。にジャンプします 楽しい失敗 ここに至るまでに私が生み出したゴミの一部を以下に示します。
改善すべき点はたくさんあると思いますが、コードの読みやすさと柔軟性には非常に満足しています。
ここに、このすべての作業の物理的な結果を示す写真がたくさんあります。





プレキシガラスも切断できます!このマシンは本当にクールです。
楽しい失敗
これらをジェネレーティブ アートと呼ぶことに同意しましょう。




考えられる改善点
もう少し時間があれば、いくつかの点を改善したいと思います。
- 私の 初期のボロノイアプローチ 本当にランダムな部分を生成することもできます。舌の部分が交差しないようにロジックを追加すると便利です。また、小さすぎる部分の生成も避ける必要があります。
- このツールは、パラメータを対話的に設定する Web サイトに簡単に作成できます。
- 摂動が十分に低い場合、実際にはピースは十分にユニークではありません。私が作ったパズルの中には、解くのが非常に難しいものもあります。
- 舌の形状を少しランダム化したいと思います。薄くしたり斜めにしたりするのはそれほど難しいことではありません。これは、それぞれの作品をユニークにするのに非常に役立ちます。
- 次のパズルを彫刻することができ、かなり簡単に解けるようになりました。
私のコードをハッキングした場合は、お気軽にプルリクエストを送ってください。
それでおしまい。
レーザ!

(レーザ)
#Elm #でパズルジェネレーターを作成する