commit 40cfdf9bc68987a3afcafa021b78c4c4191bad0b Author: Lily Rose Date: Mon Jul 28 01:54:31 2025 +1000 Initial commit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7c92c48 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27.1.2" + gleam-version: "1.11.1" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb44fe6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.beam +*.ez +/build +erl_crash.dump +**/*.svg \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1a347a8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2025 Lily Rose + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd2b32e --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# substrate + +[![Package Version](https://img.shields.io/hexpm/v/substrate)](https://hex.pm/packages/substrate) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/substrate/) + +```sh +gleam add substrate@1 +``` +```gleam +import substrate + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..3748f9a --- /dev/null +++ b/gleam.toml @@ -0,0 +1,14 @@ +name = "substrate" +version = "1.0.0" +description = "Procedurally generate SVG wallpapers from Kicad footprints" +licences = ["MIT"] +repository = { type = "forgejo", host = "git.7cs.dev", user = "lily", repo = "substrate" } + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +simplifile = ">= 2.3.0 and < 3.0.0" +lustre = ">= 5.2.1 and < 6.0.0" +kicad_sexpr = ">= 1.0.1 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..24abafa --- /dev/null +++ b/manifest.toml @@ -0,0 +1,22 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, + { name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, + { name = "kicad_sexpr", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "kicad_sexpr", source = "hex", outer_checksum = "8ACA8737C6B3517BAA9E8A8278C5B88379AE6EFED28C74F30FB9E7A19622CCF9" }, + { name = "lustre", version = "5.2.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "DCD121F8E6B7E179B27D9A8AEB6C828D8380E26DF2E16D078511EDAD1CA9F2A7" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, +] + +[requirements] +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +kicad_sexpr = { version = ">= 1.0.1 and < 2.0.0" } +lustre = { version = ">= 5.2.1 and < 6.0.0" } +simplifile = { version = ">= 2.3.0 and < 3.0.0" } diff --git a/src/substrate.gleam b/src/substrate.gleam new file mode 100644 index 0000000..61fd1ab --- /dev/null +++ b/src/substrate.gleam @@ -0,0 +1,666 @@ +import gleam/float +import gleam/list +import gleam/option.{None, Some} +import gleam/order +import kicad_sexpr +import kicad_sexpr/token.{type Footprint, type Pad} +import lustre/attribute.{style} as _attribute +import lustre/element.{type Element, fragment, to_readable_string} as _element +import lustre/element/html.{svg} +import simplifile +import svg_attribute as attribute +import svg_element as element +import svg_path as path +import svg_transform as transform + +pub fn get_footprint( + library_location: String, + library: String, + name: String, +) -> Footprint { + let path = + library_location + <> "/footprints/" + <> library + <> ".pretty/" + <> name + <> ".kicad_mod" + let assert Ok(data) = path |> simplifile.read_bits + let assert Ok(footprint_file) = kicad_sexpr.parse_footprint_file(data) + footprint_file.footprint +} + +pub fn get_local_footprint(library: String, name: String) -> Footprint { + get_footprint("/home/lily/.local/share/kicad/9.0", library, name) +} + +pub fn get_local_footprints( + library: String, + names: List(String), +) -> List(Footprint) { + names |> list.map(get_local_footprint(library, _)) +} + +pub fn get_global_footprint(library: String, name: String) -> Footprint { + get_footprint("/usr/share/kicad", library, name) +} + +pub fn get_global_footprints( + library: String, + names: List(String), +) -> List(Footprint) { + names |> list.map(get_global_footprint(library, _)) +} + +pub fn main() -> Nil { + // simplifile.get_files("/usr/share/kicad/footprints/") + // |> result.unwrap([]) + // |> list.filter_map(fn(file_name) { + // use data <- result.try( + // simplifile.read_bits(file_name) |> result.replace_error(Nil), + // ) + // use footprint_file <- result.try( + // kicad_sexpr.parse_footprint_file(data) |> result.replace_error(Nil), + // ) + // case + // footprint_file.footprint.pads + // |> list.any(fn(pad) { + // case pad.chamfer { + // option.Some([_, ..]) -> True + // _ -> False + // } + // }) + // { + // True -> Ok(file_name) + // False -> Error(Nil) + // } + // }) + // |> string.join("\n") + // |> io.println + // let capacitors = + // get_global_footprints("Capacitor_SMD", [ + // "C_0603_1608Metric", "C_0805_2012Metric", "C_1210_3225Metric", + // ]) + // let resistors = + // get_global_footprints("Resistor_SMD", [ + // "R_0603_1608Metric", "R_0805_2012Metric", "R_1210_3225Metric", + // ]) + // let sots = + // get_global_footprints("Package_TO_SOT_SMD", [ + // "SOT-23", "SOT-23-5", "SOT-23-6", "SOT-23-8", + // ]) + // let tos = + // get_global_footprints("Package_TO_SOT_SMD", ["TO-252-2", "TO-252-3_TabPin2"]) + // let ssops = + // get_global_footprints("Package_SO", [ + // "SSOP-20_4.4x6.5mm_P0.65mm", "SSOP-8_3.9x5.05mm_P1.27mm", + // "SSOP-10-1EP_3.9x4.9mm_P1mm_EP2.1x3.3mm", + // ]) + // let tqfps = + // get_global_footprints("Package_QFP", [ + // "TQFP-32_7x7mm_P0.8mm", "TQFP-44_10x10mm_P0.8mm", "TQFP-64_10x10mm_P0.5mm", + // ]) + // let dfns = + // get_global_footprints("Package_DFN_QFN", [ + // "DFN-6-1EP_3x2mm_P0.5mm_EP1.65x1.35mm", "DFN-8_2x2mm_P0.5mm", + // "DFN-14-1EP_3x4mm_P0.5mm_EP1.7x3.3mm", + // ]) + // let qfns = + // get_global_footprints("Package_DFN_QFN", [ + // "QFN-12-1EP_3x3mm_P0.51mm_EP1.45x1.45mm", + // "QFN-16-1EP_4x4mm_P0.65mm_EP2.5x2.5mm", + // "QFN-24-1EP_4x4mm_P0.5mm_EP2.65x2.65mm", + // "QFN-32-1EP_5x5mm_P0.5mm_EP3.6x3.6mm", + // ]) + let test_footprint = get_local_footprint("Testing", "Testing1") + let svg = create_svg(test_footprint) + let assert Ok(_) = simplifile.write("out.svg", svg) + Nil +} + +const bg_col = "#001021" + +fn create_svg(footprint: Footprint) -> String { + let fill = None + let stroke = None + let stroke_width = None + let transform = None + svg([attribute.view_box(-4.0, -4.0, 8.0, 8.0), style("background", bg_col)], [ + element.group( + fill:, + stroke: Some("#BBBBBB"), + stroke_width: Some(0.01), + transform:, + children: [ + element.line( + fill:, + stroke:, + stroke_width:, + transform:, + x1: -4.0, + y1: 0.0, + x2: 4.0, + y2: 0.0, + ), + element.line( + fill:, + stroke:, + stroke_width:, + transform:, + x1: 0.0, + y1: -4.0, + x2: 0.0, + y2: 4.0, + ), + ], + ), + element.group( + fill:, + stroke: Some("#FF00FF"), + stroke_width: Some(0.01), + transform:, + children: [ + element.line( + fill:, + stroke:, + stroke_width:, + transform:, + x1: -0.05, + y1: 0.0, + x2: 0.05, + y2: 0.0, + ), + element.line( + fill:, + stroke:, + stroke_width:, + transform:, + x1: 0.0, + y1: -0.05, + x2: 0.0, + y2: 0.05, + ), + ], + ), + element.group( + fill: Some("#CE3431"), + stroke:, + stroke_width:, + transform:, + children: footprint.pads + |> list.filter(fn(pad) { + pad.layers |> list.contains(token.Layer("F.Cu")) + || pad.layers |> list.contains(token.Layer("*.Cu")) + }) + |> list.map(pad_to_element), + ), + ]) + |> to_readable_string +} + +fn pad_to_element(pad: Pad) -> Element(a) { + let token.PositionIdentifier(x:, y:, angle:, ..) = pad.position + let token.Size(width: w, height: h) = pad.size + let nw = -1.0 *. w + let nh = -1.0 *. h + let hw = w /. 2.0 + let hh = h /. 2.0 + let fill = None + let stroke = None + let stroke_width = None + let transform = + angle + |> option.map(fn(angle) { + transform.Transform( + translate: None, + rotate: Some(transform.RotateAround(angle: -1.0 *. angle, x:, y:)), + scale: None, + ) + }) + let #(ox, oy) = case pad.drill { + Some(token.PadDrillDefinition(offset: Some(token.XY(x: dx, y: dy)), ..)) -> #( + x +. dx, + y +. dy, + ) + _ -> #(pad.position.x, pad.position.y) + } + let elem = case pad.shape { + token.CirclePadShape -> + element.circle( + fill:, + stroke:, + stroke_width:, + transform:, + cx: ox, + cy: oy, + r: hw, + ) + token.OvalPadShape -> + case float.compare(w, h) { + order.Eq -> + element.circle( + fill:, + stroke:, + stroke_width:, + transform:, + cx: ox, + cy: oy, + r: hw, + ) + order.Lt -> + element.path(fill:, stroke:, stroke_width:, transform:, path: [ + path.MoveAbsolute(x: ox +. hw, y: oy +. hw -. hh), + path.ArcCurveRelative( + rx: hw, + ry: hw, + angle: 180.0, + large: False, + clockwise: True, + dx: nw, + dy: 0.0, + ), + path.VerticalLineRelative(h -. w), + path.ArcCurveRelative( + rx: hw, + ry: hw, + angle: 180.0, + large: False, + clockwise: True, + dx: w, + dy: 0.0, + ), + path.ClosePath, + ]) + order.Gt -> + element.path(fill:, stroke:, stroke_width:, transform:, path: [ + path.MoveAbsolute(x: ox +. hh -. hw, y: oy -. hh), + path.ArcCurveRelative( + rx: hh, + ry: hh, + angle: 180.0, + large: False, + clockwise: True, + dx: 0.0, + dy: h, + ), + path.HorizontalLineRelative(w -. h), + path.ArcCurveRelative( + rx: hh, + ry: hh, + angle: 180.0, + large: False, + clockwise: True, + dx: 0.0, + dy: nh, + ), + path.ClosePath, + ]) + } + token.RectanglePadShape | token.RoundRectPadShape -> { + let rounding = case pad.shape { + token.RoundRectPadShape -> pad.roundrect_rratio + _ -> None + } + let rounding = case rounding { + Some(value) if value <=. 0.0 -> None + Some(value) if value >. 5.0 -> Some(5.0) + Some(value) -> Some(value) + None -> None + } + let chamfering = case pad.chamfer_ratio { + Some(value) if value <=. 0.0 -> None + Some(value) if value >. 5.0 -> Some(5.0) + Some(value) -> Some(value) + None -> None + } + case rounding, pad.chamfer, chamfering { + None, Some([]), _ | None, None, _ | None, _, None -> + element.centered_rect( + fill:, + stroke:, + stroke_width:, + transform:, + x: ox, + y: oy, + width: w, + height: h, + ) + Some(rounding), Some([]), _ + | Some(rounding), None, _ + | Some(rounding), _, None + -> { + let radius = rounding *. float.min(w, h) + element.centered_rounded_rect( + fill:, + stroke:, + stroke_width:, + transform:, + x: ox, + y: oy, + rx: radius, + ry: radius, + width: w, + height: h, + ) + } + None, Some(corners), Some(chamfering) -> { + let #(tl, tr, bl, br) = chamfered_corners(corners) + let o = chamfering *. float.min(w, h) + let no = -1.0 *. o + let path = [path.ClosePath] + let bro = case br { + True -> o + False -> 0.0 + } + let path = case tr { + True -> [ + path.VerticalLineRelative(dy: nh +. bro +. o), + path.LineRelative(dx: no, dy: no), + ..path + ] + False -> [path.VerticalLineRelative(dy: nh +. bro), ..path] + } + let blo = case bl { + True -> o + False -> 0.0 + } + let path = case br { + True -> [ + path.HorizontalLineRelative(dx: w -. blo -. o), + path.LineRelative(dx: o, dy: no), + ..path + ] + False -> [path.HorizontalLineRelative(dx: w -. blo), ..path] + } + let tlo = case tl { + True -> o + False -> 0.0 + } + let path = case bl { + True -> [ + path.VerticalLineRelative(dy: h -. tlo -. o), + path.LineRelative(dx: o, dy: o), + ..path + ] + False -> [path.VerticalLineRelative(dy: h -. tlo), ..path] + } + let path = case tl { + True -> [ + path.MoveAbsolute(x: ox -. hw +. o, y: oy -. hh), + path.LineRelative(dx: no, dy: o), + ..path + ] + False -> [path.MoveAbsolute(x: ox -. hw, y: oy -. hh), ..path] + } + element.path(fill:, stroke:, stroke_width:, transform:, path:) + } + Some(rounding), Some(corners), Some(chamfering) -> { + let #(tl, tr, bl, br) = chamfered_corners(corners) + let o = chamfering *. float.min(w, h) + let no = -1.0 *. o + let r = rounding *. float.min(w, h) + let nr = -1.0 *. r + let path = [path.ClosePath] + let bro = case br { + True -> o + False -> r + } + let path = case tr { + True -> [ + path.VerticalLineRelative(dy: nh +. bro +. o), + path.LineRelative(dx: no, dy: no), + ..path + ] + False -> [ + path.VerticalLineRelative(dy: nh +. bro +. r), + path.ArcCurveRelative( + rx: r, + ry: r, + angle: 90.0, + large: True, + clockwise: True, + dx: nr, + dy: nr, + ), + ..path + ] + } + let blo = case bl { + True -> o + False -> r + } + let path = case br { + True -> [ + path.HorizontalLineRelative(dx: w -. blo -. o), + path.LineRelative(dx: o, dy: no), + ..path + ] + False -> [ + path.HorizontalLineRelative(dx: w -. blo -. r), + path.ArcCurveRelative( + rx: r, + ry: r, + angle: 90.0, + large: True, + clockwise: True, + dx: r, + dy: nr, + ), + ..path + ] + } + let tlo = case tl { + True -> o + False -> r + } + let path = case bl { + True -> [ + path.VerticalLineRelative(dy: h -. tlo -. o), + path.LineRelative(dx: o, dy: o), + ..path + ] + False -> [ + path.VerticalLineRelative(dy: h -. tlo -. r), + path.ArcCurveRelative( + rx: r, + ry: r, + angle: 90.0, + large: True, + clockwise: True, + dx: r, + dy: r, + ), + ..path + ] + } + let path = case tl { + True -> [ + path.MoveAbsolute(x: ox -. hw +. o, y: oy -. hh), + path.LineRelative(dx: no, dy: o), + ..path + ] + False -> [ + path.MoveAbsolute(x: ox -. hw +. r, y: oy -. hh), + path.ArcCurveRelative( + rx: r, + ry: r, + angle: 90.0, + large: True, + clockwise: True, + dx: nr, + dy: r, + ), + ..path + ] + } + element.path(fill:, stroke:, stroke_width:, transform:, path:) + } + } + } + token.TrapezoidPadShape -> + case pad.rect_delta { + Some(token.XY(x: 0.0, y: o)) -> { + let no = -1.0 *. o + let ho = o /. 2.0 + let path = [ + path.MoveAbsolute(x: ox -. hw +. ho, y: oy -. hh), + path.LineRelative(dx: no, dy: h), + path.HorizontalLineRelative(dx: w +. o), + path.LineRelative(dx: no, dy: nh), + path.ClosePath, + ] + element.path(fill:, stroke:, stroke_width:, transform:, path:) + } + Some(token.XY(x: o, y: 0.0)) -> { + let no = -1.0 *. o + let ho = o /. 2.0 + let path = [ + path.MoveAbsolute(x: ox -. hw, y: oy +. hh +. ho), + path.LineRelative(dx: w, dy: no), + path.VerticalLineRelative(dy: nh +. o), + path.LineRelative(dx: nw, dy: no), + path.ClosePath, + ] + element.path(fill:, stroke:, stroke_width:, transform:, path:) + } + _ -> + element.centered_rect( + fill:, + stroke:, + stroke_width:, + transform:, + x: ox, + y: oy, + width: w, + height: h, + ) + } + token.CustomPadShape -> + case pad.custom_options { + Some(token.CustomPadOptions(anchor: token.CircleAnchorPadShape, ..)) -> + element.circle( + fill:, + stroke:, + stroke_width:, + transform:, + cx: ox, + cy: oy, + r: hw, + ) + Some(token.CustomPadOptions(anchor: token.RectangleAnchorPadShape, ..)) + | None -> + element.centered_rect( + fill:, + stroke:, + stroke_width:, + transform:, + x: ox, + y: oy, + width: w, + height: h, + ) + } + } + let fill = Some(bg_col) + let stroke = Some("#E7B629") + let stroke_width = Some(0.03333) + case pad.drill { + Some(token.PadDrillDefinition(False, Some(d), ..)) + | Some(token.PadDrillDefinition(True, Some(d), None, ..)) -> + fragment([ + elem, + element.circle( + fill:, + stroke:, + stroke_width:, + transform:, + cx: x, + cy: y, + r: d /. 2.0, + ), + ]) + Some(token.PadDrillDefinition(True, Some(w), Some(h), ..)) -> { + let hw = w /. 2.0 + let hh = h /. 2.0 + let nw = -1.0 *. w + let nh = -1.0 *. h + fragment([ + elem, + case float.compare(w, h) { + order.Eq -> + element.circle( + fill:, + stroke:, + stroke_width:, + transform:, + cx: x, + cy: y, + r: hw, + ) + order.Lt -> + element.path(fill:, stroke:, stroke_width:, transform:, path: [ + path.MoveAbsolute(x: x +. hw, y: y +. hw -. hh), + path.ArcCurveRelative( + rx: hw, + ry: hw, + angle: 180.0, + large: False, + clockwise: True, + dx: nw, + dy: 0.0, + ), + path.VerticalLineRelative(h -. w), + path.ArcCurveRelative( + rx: hw, + ry: hw, + angle: 180.0, + large: False, + clockwise: True, + dx: w, + dy: 0.0, + ), + path.ClosePath, + ]) + order.Gt -> + element.path(fill:, stroke:, stroke_width:, transform:, path: [ + path.MoveAbsolute(x: x +. hh -. hw, y: y -. hh), + path.ArcCurveRelative( + rx: hh, + ry: hh, + angle: 180.0, + large: False, + clockwise: True, + dx: 0.0, + dy: h, + ), + path.HorizontalLineRelative(w -. h), + path.ArcCurveRelative( + rx: hh, + ry: hh, + angle: 180.0, + large: False, + clockwise: True, + dx: 0.0, + dy: nh, + ), + path.ClosePath, + ]) + }, + ]) + } + _ -> elem + } +} + +fn chamfered_corners(corners: List(token.Corner)) { + corners + |> list.fold(#(False, False, False, False), fn(corners, corner) { + let #(top_left, top_right, bottom_left, bottom_right) = corners + case corner { + token.TopLeft -> #(True, top_right, bottom_left, bottom_right) + token.TopRight -> #(top_left, True, bottom_left, bottom_right) + token.BottomLeft -> #(top_left, top_right, True, bottom_right) + token.BottomRight -> #(top_left, top_right, bottom_left, True) + } + }) +} diff --git a/src/svg_attribute.gleam b/src/svg_attribute.gleam new file mode 100644 index 0000000..1845b7c --- /dev/null +++ b/src/svg_attribute.gleam @@ -0,0 +1,104 @@ +import gleam/float +import gleam/list +import gleam/string +import lustre/attribute.{type Attribute, attribute} +import svg_path.{type Path} +import svg_transform.{type Transform} + +pub fn view_box( + min_x: Float, + min_y: Float, + width: Float, + height: Float, +) -> Attribute(a) { + attribute( + "viewBox", + [min_x, min_y, width, height] + |> list.map(float.to_string) + |> string.join(" "), + ) +} + +fn float_attribute(name: String, value: Float) -> Attribute(a) { + attribute(name, value |> float.to_precision(6) |> float.to_string) +} + +pub fn fill(value: String) -> Attribute(a) { + attribute("fill", value) +} + +pub fn stroke(value: String) -> Attribute(a) { + attribute("stroke", value) +} + +pub fn stroke_width(value: Float) -> Attribute(a) { + float_attribute("stroke-width", value) +} + +pub fn width(value: Float) -> Attribute(a) { + float_attribute("width", value) +} + +pub fn height(value: Float) -> Attribute(a) { + float_attribute("height", value) +} + +pub fn x(value: Float) -> Attribute(a) { + float_attribute("x", value) +} + +pub fn y(value: Float) -> Attribute(a) { + float_attribute("y", value) +} + +pub fn dx(value: Float) -> Attribute(a) { + float_attribute("dx", value) +} + +pub fn dy(value: Float) -> Attribute(a) { + float_attribute("dy", value) +} + +pub fn x1(value: Float) -> Attribute(a) { + float_attribute("x1", value) +} + +pub fn y1(value: Float) -> Attribute(a) { + float_attribute("y1", value) +} + +pub fn x2(value: Float) -> Attribute(a) { + float_attribute("x2", value) +} + +pub fn y2(value: Float) -> Attribute(a) { + float_attribute("y2", value) +} + +pub fn rx(value: Float) -> Attribute(a) { + float_attribute("rx", value) +} + +pub fn ry(value: Float) -> Attribute(a) { + float_attribute("ry", value) +} + +pub fn cx(value: Float) -> Attribute(a) { + float_attribute("cx", value) +} + +pub fn cy(value: Float) -> Attribute(a) { + float_attribute("cy", value) +} + +pub fn r(value: Float) -> Attribute(a) { + float_attribute("r", value) +} + +pub fn d(value: Path) -> Attribute(a) { + attribute("d", svg_path.to_string(value)) +} + +pub fn transform(value: Transform) -> Attribute(a) { + attribute("transform", svg_transform.to_string(value)) +} diff --git a/src/svg_element.gleam b/src/svg_element.gleam new file mode 100644 index 0000000..e72db94 --- /dev/null +++ b/src/svg_element.gleam @@ -0,0 +1,221 @@ +import gleam/option.{type Option, None, Some} +import lustre/attribute.{type Attribute} +import lustre/element.{type Element, element} +import svg_attribute +import svg_path.{type Path} +import svg_transform.{type Transform} + +fn styles( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), +) -> List(Attribute(a)) { + let attrs = [] + let attrs = case transform { + Some(value) -> [svg_attribute.transform(value), ..attrs] + None -> attrs + } + let attrs = case stroke_width { + Some(value) -> [svg_attribute.stroke_width(value), ..attrs] + None -> attrs + } + let attrs = case stroke { + Some(value) -> [svg_attribute.stroke(value), ..attrs] + None -> attrs + } + let attrs = case fill { + Some(value) -> [svg_attribute.fill(value), ..attrs] + None -> attrs + } + attrs +} + +pub fn group( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + children children: List(Element(a)), +) -> Element(a) { + element("g", styles(fill:, stroke:, stroke_width:, transform:), children) +} + +pub fn line( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + x1 x1: Float, + y1 y1: Float, + x2 x2: Float, + y2 y2: Float, +) -> Element(a) { + element( + "line", + [ + svg_attribute.x1(x1), + svg_attribute.y1(y1), + svg_attribute.x2(x2), + svg_attribute.y2(y2), + ..styles(fill:, stroke:, stroke_width:, transform:) + ], + [], + ) +} + +pub fn circle( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + cx cx: Float, + cy cy: Float, + r r: Float, +) -> Element(a) { + element( + "circle", + [ + svg_attribute.cx(cx), + svg_attribute.cy(cy), + svg_attribute.r(r), + ..styles(fill:, stroke:, stroke_width:, transform:) + ], + [], + ) +} + +pub fn ellipse( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + cx cx: Float, + cy cy: Float, + rx rx: Float, + ry ry: Float, +) -> Element(a) { + element( + "ellipse", + [ + svg_attribute.cx(cx), + svg_attribute.cy(cy), + svg_attribute.rx(rx), + svg_attribute.ry(ry), + ..styles(fill:, stroke:, stroke_width:, transform:) + ], + [], + ) +} + +pub fn rect( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + x x: Float, + y y: Float, + width width: Float, + height height: Float, +) -> Element(a) { + element( + "rect", + [ + svg_attribute.x(x), + svg_attribute.y(y), + svg_attribute.width(width), + svg_attribute.height(height), + ..styles(fill:, stroke:, stroke_width:, transform:) + ], + [], + ) +} + +pub fn rounded_rect( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + x x: Float, + y y: Float, + rx rx: Float, + ry ry: Float, + width width: Float, + height height: Float, +) -> Element(a) { + element( + "rect", + [ + svg_attribute.x(x), + svg_attribute.y(y), + svg_attribute.rx(rx), + svg_attribute.ry(ry), + svg_attribute.width(width), + svg_attribute.height(height), + ..styles(fill:, stroke:, stroke_width:, transform:) + ], + [], + ) +} + +pub fn centered_rect( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + x x: Float, + y y: Float, + width width: Float, + height height: Float, +) -> Element(a) { + rect( + fill:, + stroke:, + stroke_width:, + transform:, + x: x -. width /. 2.0, + y: y -. height /. 2.0, + width:, + height:, + ) +} + +pub fn centered_rounded_rect( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + x x: Float, + y y: Float, + rx rx: Float, + ry ry: Float, + width width: Float, + height height: Float, +) -> Element(a) { + rounded_rect( + fill:, + stroke:, + stroke_width:, + transform:, + x: x -. width /. 2.0, + y: y -. height /. 2.0, + rx:, + ry:, + width:, + height:, + ) +} + +pub fn path( + fill fill: Option(String), + stroke stroke: Option(String), + stroke_width stroke_width: Option(Float), + transform transform: Option(Transform), + path path: Path, +) -> Element(a) { + element( + "path", + [svg_attribute.d(path), ..styles(fill:, stroke:, stroke_width:, transform:)], + [], + ) +} diff --git a/src/svg_path.gleam b/src/svg_path.gleam new file mode 100644 index 0000000..b1a5528 --- /dev/null +++ b/src/svg_path.gleam @@ -0,0 +1,138 @@ +import gleam/bool +import gleam/float +import gleam/list +import gleam/string + +pub type PathCommand { + MoveAbsolute(x: Float, y: Float) + MoveRelative(dx: Float, dy: Float) + LineAbsolute(x: Float, y: Float) + LineRelative(dx: Float, dy: Float) + HorizontalLineAbsolute(x: Float) + HorizontalLineRelative(dx: Float) + VerticalLineAbsolute(y: Float) + VerticalLineRelative(dy: Float) + CubicBezierAbsolute( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + x: Float, + y: Float, + ) + CubicBezierRelative( + dx1: Float, + dy1: Float, + dx2: Float, + dy2: Float, + dx: Float, + dy: Float, + ) + SmoothCubicBezierAbsolute(x2: Float, y2: Float, x: Float, y: Float) + SmoothCubicBezierRelative(dx2: Float, dy2: Float, dx: Float, dy: Float) + QuadraticBezierAbsolute(x1: Float, y1: Float, x: Float, y: Float) + QuadraticBezierRelative(dx1: Float, dy1: Float, dx: Float, dy: Float) + SmoothQuadraticBezierAbsolute(x: Float, y: Float) + SmoothQuadraticBezierRelative(dx: Float, dy: Float) + ArcCurveAbsolute( + rx: Float, + ry: Float, + angle: Float, + large: Bool, + clockwise: Bool, + x: Float, + y: Float, + ) + ArcCurveRelative( + rx: Float, + ry: Float, + angle: Float, + large: Bool, + clockwise: Bool, + dx: Float, + dy: Float, + ) + ClosePath +} + +fn float_to_string(value: Float) -> String { + value |> float.to_precision(6) |> float.to_string +} + +fn mk_str(id: String, values: List(Float)) -> String { + id <> " " <> values |> list.map(float_to_string) |> string.join(" ") +} + +fn bool_str(value: Bool) -> String { + case value { + True -> "0" + False -> "1" + } +} + +pub fn command_to_string(command: PathCommand) -> String { + case command { + MoveAbsolute(x:, y:) -> mk_str("M", [x, y]) + MoveRelative(dx:, dy:) -> mk_str("m", [dx, dy]) + LineAbsolute(x:, y:) -> mk_str("L", [x, y]) + LineRelative(dx:, dy:) -> mk_str("l", [dx, dy]) + HorizontalLineAbsolute(x:) -> mk_str("H", [x]) + HorizontalLineRelative(dx:) -> mk_str("h", [dx]) + VerticalLineAbsolute(y:) -> mk_str("V", [y]) + VerticalLineRelative(dy:) -> mk_str("v", [dy]) + CubicBezierAbsolute(x1:, y1:, x2:, y2:, x:, y:) -> + mk_str("C", [x1, y1, x2, y2, x, y]) + CubicBezierRelative(dx1:, dy1:, dx2:, dy2:, dx:, dy:) -> + mk_str("c", [dx1, dy1, dx2, dy2, dx, dy]) + SmoothCubicBezierAbsolute(x2:, y2:, x:, y:) -> mk_str("S", [x2, y2, x, y]) + SmoothCubicBezierRelative(dx2:, dy2:, dx:, dy:) -> + mk_str("s", [dx2, dy2, dx, dy]) + QuadraticBezierAbsolute(x1:, y1:, x:, y:) -> mk_str("Q", [x1, y1, x, y]) + QuadraticBezierRelative(dx1:, dy1:, dx:, dy:) -> + mk_str("q", [dx1, dy1, dx, dy]) + SmoothQuadraticBezierAbsolute(x:, y:) -> mk_str("T", [x, y]) + SmoothQuadraticBezierRelative(dx:, dy:) -> mk_str("t", [dx, dy]) + ArcCurveAbsolute(rx:, ry:, angle:, large:, clockwise:, x:, y:) -> + mk_str("A", [rx, ry, angle]) + <> " " + <> bool_str(large) + <> " " + <> bool_str(clockwise) + <> " " + <> float_to_string(x) + <> " " + <> float_to_string(y) + ArcCurveRelative(rx:, ry:, angle:, large:, clockwise:, dx:, dy:) -> + mk_str("a", [rx, ry, angle]) + <> " " + <> bool_str(large) + <> " " + <> bool_str(clockwise) + <> " " + <> float_to_string(dx) + <> " " + <> float_to_string(dy) + ClosePath -> "Z" + } +} + +pub type Path = + List(PathCommand) + +fn not_redundant(command: PathCommand) { + case command { + MoveRelative(dx: 0.0, dy: 0.0) -> False + LineRelative(dx: 0.0, dy: 0.0) -> False + HorizontalLineRelative(dx: 0.0) -> False + VerticalLineRelative(dy: 0.0) -> False + _ -> True + } +} + +pub fn to_string(path: Path) -> String { + use <- bool.guard(path == [], " ") + path + |> list.filter(not_redundant) + |> list.map(command_to_string) + |> string.join(" ") +} diff --git a/src/svg_transform.gleam b/src/svg_transform.gleam new file mode 100644 index 0000000..bc44921 --- /dev/null +++ b/src/svg_transform.gleam @@ -0,0 +1,62 @@ +import gleam/float +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string + +pub type Translate { + Translate(x: Float, y: Float) +} + +pub type Rotate { + Rotate(angle: Float) + RotateAround(angle: Float, x: Float, y: Float) +} + +pub type Scale { + ScaleProportional(scale: Float) + ScaleStretched(scale_x: Float, scale_y: Float) +} + +pub type Transform { + Transform( + translate: Option(Translate), + rotate: Option(Rotate), + scale: Option(Scale), + ) +} + +fn float_to_string(value: Float) -> String { + value |> float.to_precision(6) |> float.to_string +} + +fn mk_str(name: String, values: List(Float)) -> String { + name + <> "(" + <> { values |> list.map(float_to_string) |> string.join(" ") } + <> ")" +} + +pub fn to_string(transform: Transform) -> String { + let parts = [] + let parts = case transform.scale { + Some(ScaleProportional(scale:)) -> [mk_str("scale", [scale]), ..parts] + Some(ScaleStretched(scale_x:, scale_y:)) -> [ + mk_str("scale", [scale_x, scale_y]), + ..parts + ] + None -> parts + } + let parts = case transform.rotate { + Some(Rotate(angle:)) -> [mk_str("rotate", [angle]), ..parts] + Some(RotateAround(angle:, x:, y:)) -> [ + mk_str("rotate", [angle, x, y]), + ..parts + ] + None -> parts + } + let parts = case transform.translate { + Some(Translate(x:, y:)) -> [mk_str("translate", [x, y]), ..parts] + None -> parts + } + parts |> string.join(" ") +} diff --git a/test/substrate_test.gleam b/test/substrate_test.gleam new file mode 100644 index 0000000..902c4da --- /dev/null +++ b/test/substrate_test.gleam @@ -0,0 +1,5 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +}