diff --git a/.claude/skills/cetz-figures/SKILL.md b/.claude/skills/cetz-figures/SKILL.md index f65e81a3a..5d48bcc02 100644 --- a/.claude/skills/cetz-figures/SKILL.md +++ b/.claude/skills/cetz-figures/SKILL.md @@ -120,6 +120,12 @@ holds `mesh-demo.*` and `domain-demo.*` for the post The JSON intermediate is the forward bridge to underworld3 — see `underworld-bridge.md`. +## House style + +Line weights, dash patterns, arrow classes and colours for every drawn +figure are fixed by the `uw-line-diagrams` skill; import its `uwfig.typ` +(`#import "uwfig.typ": *`) rather than choosing strokes per figure. + ## Reference files - `cetz-cheatsheet.md` — what worked from memory vs. needed lookup. diff --git a/.claude/skills/uw-line-diagrams/SKILL.md b/.claude/skills/uw-line-diagrams/SKILL.md new file mode 100644 index 000000000..032e08519 --- /dev/null +++ b/.claude/skills/uw-line-diagrams/SKILL.md @@ -0,0 +1,100 @@ +--- +name: uw-line-diagrams +description: House style for underworld3 line diagrams and schematics — mesh figures with boundary conditions, domain sketches, control volumes, flow schematics — for docstrings, notebooks, benchmark ledgers and articles. Use whenever a figure is DRAWN rather than rendered from a field; it fixes line weights, dash patterns, arrow classes, colours, fonts and output formats so every diagram in the project looks the same. Field renders (colormaps, streamlines from data) belong to uw-visualisation; Typst-native drawing mechanics to cetz-figures. +--- + +# uw-line-diagrams + +One look for every drawn figure in underworld3. The full reference, with the +journal specifications and the sources behind them, is +`line-drawing-conventions.md` in this directory. This file is the working +recipe; the two style files are the single source of the numbers: + +- `uwfig.py` — matplotlib: colour tokens, line roles, arrow classes, rcParams, + and helpers (`figure`, `draw_mesh`, `boundary`, `inflow_profile`, `label`, + `dimension`, `axes_glyph`, `save`). Import it; never restyle locally. +- `uwfig.typ` — the same roles for Typst + CeTZ figures. +- `examples/benchmark_meshes.py` — the three benchmark mesh figures + (DFG cylinder channel, Waters and King box, plain channel) built with `uwfig`. + +## Which tool + +| The figure is mostly | Use | +|---|---| +| computed geometry: a real mesh, a real boundary, data-derived curves | `uwfig.py` (matplotlib), export PDF + SVG + PNG | +| annotation and math-typeset labels that must match a Typst paper | `uwfig.typ` with the `cetz-figures` skill | +| a field (colormap, vorticity, streamlines from a solution) | `uw-visualisation` (PyVista); this skill only styles any overlay | + +## The rules that decide the look + +1. **Line weight and dash pattern carry the meaning; colour is secondary.** + Three weights only, at final size: 1.2 pt physical boundary, 0.8 pt + interface / hidden / control volume, 0.5 pt streamlines and construction. + A reader must be able to classify every line in grayscale. +2. **Mesh lines are light and thin** (0.25 pt, grey) and sit under the + boundary. The mesh is context; the boundary and its conditions are the + subject. A zoom inset may go to 0.35 pt. +3. **One arrow class per physical quantity.** Velocity: teal, 0.9 pt. + Force / traction / applied stress: rust, 1.3 pt. Energy / heat: plum. + Dimensions: ink, 0.5 pt, double-headed. Never reuse a class for another + quantity, never encode a distinction in red vs green. +4. **Draw the condition, do not describe it.** An inlet is a parabola with + arrows into the domain (`inflow_profile`), an outlet the same pointing + out, a body force a row of equal arrows, a wall a hatched strip. A few + words of label at most: `no-slip`, `$v = 0$`, `$R = 0.05$`. +5. **No caption material on the figure.** No titles, no sentences, no + parameter lists, no explanation of what the reader is looking at. The + caption in the document does that; the figure stays uncluttered. +6. **Direct labels, not legends.** Put `$u$`, `$p$`, `$G$` on the drawing + with a leader where needed. Show the coordinate frame (`axes_glyph`) and + any gravity or rotation explicitly. +7. **One sans-serif face, 8 pt at final size,** text kept as text in PDF and + SVG (`uwfig.py` sets Helvetica / Arial with fallbacks, `pdf.fonttype 42`, + `svg.fonttype none`). Symbols in mathtext so they match the manuscript. +8. **Design at final width**: 90 mm single column or 183 mm double + (`figure(width_mm=...)`). Nothing below 0.25 pt, no text below 6 pt after + reduction. Check the grayscale conversion before shipping. +9. **Vector first.** `save(fig, stem)` writes `.pdf`, `.svg` and a 300 dpi + `.png`; the PNG is for notebooks and web pages, the PDF/SVG for articles. +10. **Exaggerations are declared** in the caption (boundary-layer thickness, + aspect ratio), never silently drawn. + +## Recipe + +```python +import sys; sys.path.insert(0, "/.claude/skills/uw-line-diagrams") +from uwfig import * +import underworld3 as uw + +mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(2.2, 0.41), cellSize=0.05, qdegree=3) +fig, ax = figure(width_mm=183, aspect=0.28) +draw_mesh(ax, mesh) +boundary(ax, [(0, 0), (2.2, 0), (2.2, 0.41), (0, 0.41)]) +inflow_profile(ax, 0.0, 0.0, 0.41, umax=1.5, side="left", scale=0.12) +inflow_profile(ax, 2.2, 0.0, 0.41, umax=1.5, side="right", scale=0.12) +label(ax, 1.1, 0.48, "no-slip"); label(ax, 1.1, -0.07, "no-slip") +axes_glyph(ax, (-0.25, -0.15), 0.12) +ax.set_xlim(-0.35, 2.55); ax.set_ylim(-0.22, 0.63) +save(fig, "figures/mesh_channel") +``` + +Where the figure lives: source (`.py` or `.typ`) next to its outputs in a +`figures/` directory of the document that uses it, regenerated by running the +source. Docstring figures go under `docs/` with the page that shows them. + +## Checklist before shipping + +- [ ] vector output written; text not outlined +- [ ] final column width; at most three line weights, none under 0.25 pt +- [ ] every line role and arrow class distinguishable in grayscale +- [ ] inlet / outlet / walls / forcing drawn, with short labels only +- [ ] no caption text on the figure +- [ ] coordinate frame shown when the reader could need it +- [ ] symbols match the manuscript (italic variables, upright units) +- [ ] source under version control; one command regenerates the figure + +## Related skills + +- `cetz-figures` — Typst mechanics, gotchas, the JSON geometry bridge. +- `uw-visualisation` — PyVista field renders; the render rules for vorticity + and colour ranges live there. diff --git a/.claude/skills/uw-line-diagrams/examples/benchmark_meshes.py b/.claude/skills/uw-line-diagrams/examples/benchmark_meshes.py new file mode 100644 index 000000000..c9b1b409f --- /dev/null +++ b/.claude/skills/uw-line-diagrams/examples/benchmark_meshes.py @@ -0,0 +1,104 @@ +"""The benchmark mesh figures in the house style: DFG cylinder channel, Waters and King +start-up box, plain channel. Run from a pixi environment with underworld3 built: + + python benchmark_meshes.py [outdir] + +Writes mesh_.{pdf,svg,png} into outdir (default: ./figures). +""" +import os, sys +import numpy as np +import underworld3 as uw + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +from uwfig import * + +OUT = sys.argv[1] if len(sys.argv) > 1 else "figures" +W, H, R, C = 2.2, 0.41, 0.05, (0.2, 0.2) + + +def cylinder_mesh(resolution=10, circle_ratio=0.25, refinement=1): + """The DFG channel with the cylinder cells a quarter of the bulk, refined once with the + new vertices snapped back to the circle (the mesh the benchmark runs use).""" + from enum import Enum + class boundaries(Enum): + bottom = 1; right = 2; top = 3; left = 4; inclusion = 5; All_Boundaries = 1001 + csize = 1.0 / resolution + os.makedirs(".meshes", exist_ok=True) + mesh_file = f".meshes/dfg_cylinder_{resolution}.msh" + if uw.mpi.rank == 0 and not os.path.exists(mesh_file): + import pygmsh + with pygmsh.geo.Geometry() as geom: + geom.characteristic_length_max = csize + inclusion = geom.add_circle((C[0], C[1], 0.0), R, make_surface=False, mesh_size=circle_ratio * csize) + domain = geom.add_rectangle(xmin=0.0, ymin=0.0, xmax=W, ymax=H, z=0, holes=[inclusion], mesh_size=csize) + for i, b in enumerate((boundaries.bottom, boundaries.right, boundaries.top, boundaries.left)): + geom.add_physical(domain.surface.curve_loop.curves[i], label=b.name) + geom.add_physical(inclusion.curve_loop.curves, label=boundaries.inclusion.name) + geom.add_physical(domain.surface, label="Elements") + geom.generate_mesh(dim=2, verbose=False) + geom.save_geometry(mesh_file) + uw.mpi.comm.barrier() + + def snap(dm): + c2 = dm.getCoordinatesLocal() + coords = c2.array.reshape(-1, 2) - np.array(C) + r = np.sqrt((coords ** 2).sum(1)).reshape(-1, 1) + idx = uw.cython.petsc_discretisation.petsc_dm_find_labeled_points_local(dm, "inclusion") + coords[idx] *= R / r[idx] + c2.array[...] = (coords + np.array(C)).reshape(-1); dm.setCoordinatesLocal(c2) + + return uw.discretisation.Mesh(mesh_file, markVertices=True, useMultipleTags=True, useRegions=True, + refinement=refinement, refinement_callback=snap, boundaries=boundaries, qdegree=3) + + +# ---------------- 1. DFG cylinder channel with two zoom insets ---------------- +tris = mesh_triangles(cylinder_mesh()) +fig, ax = figure(width_mm=183, aspect=0.40) +ax.set_position([0.03, 0.44, 0.94, 0.54]) +draw_mesh(ax, tris) +boundary(ax, [(0, 0), (W, 0), (W, H), (0, H)]) +circle_body(ax, C, R) +inflow_profile(ax, 0.0, 0.0, H, 1.5, side="left", scale=0.12) +inflow_profile(ax, W, 0.0, H, 1.5, side="right", scale=0.12) +label(ax, -0.26, H / 2, "inlet", rot=90, color=VELOCITY) +label(ax, W + 0.26, H / 2, "outlet", rot=270, color=VELOCITY) +label(ax, W / 2, H + 0.07, "no-slip"); label(ax, W / 2, -0.07, "no-slip") +leader(ax, (0.42, 0.33), (C[0] + R * 0.75, C[1] + R * 0.7), "no-slip, $R = 0.05$") +axes_glyph(ax, (-0.32, -0.15), 0.1) +ax.set_xlim(-0.36, W + 0.34); ax.set_ylim(-0.18, H + 0.17) +for pos, (x0, x1, y0, y1) in (([0.06, 0.02, 0.36, 0.38], (0.05, 0.55, 0.0, H)), + ([0.58, 0.02, 0.36, 0.38], (C[0] - 2 * R, C[0] + 2 * R, C[1] - 2 * R, C[1] + 2 * R))): + axz = fig.add_axes(pos); axz.set_aspect("equal") + draw_mesh(axz, tris, lw=0.35) + circle_body(axz, C, R) + axz.set_xlim(x0, x1); axz.set_ylim(y0, y1); axz.set_xticks([]); axz.set_yticks([]) + for s in axz.spines.values(): s.set_edgecolor(INK); s.set_linewidth(0.6) +save(fig, os.path.join(OUT, "mesh_dfg_cylinder_1_20")) + +# ---------------- 2. Waters and King start-up box: walls at top and bottom, body force G ---------------- +mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=1 / 16, qdegree=3, regular=True) +fig, ax = figure(width_mm=90, aspect=0.95) +draw_mesh(ax, mesh) +boundary(ax, [(-1, -1), (1, -1), (1, 1), (-1, 1)]) +label(ax, 0, 1.14, "no-slip"); label(ax, 0, -1.14, "no-slip") +label(ax, -1.22, 0, "$v = 0$", rot=90, color=VELOCITY); label(ax, 1.22, 0, "$v = 0$", rot=270, color=VELOCITY) +uniform_arrows(ax, -0.25, 0.25, np.linspace(-0.8, 0.8, 5), kind="force") +label(ax, 0.45, 0.0, "$G$", ha="left", color=FORCE) +axes_glyph(ax, (-1.45, -1.35), 0.25) +ax.set_xlim(-1.55, 1.55); ax.set_ylim(-1.45, 1.45) +save(fig, os.path.join(OUT, "mesh_waters_king_box")) + +# ---------------- 3. Plain channel (no cylinder) ---------------- +mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(W, H), cellSize=0.05, qdegree=3) +fig, ax = figure(width_mm=183, aspect=0.27) +draw_mesh(ax, mesh, lw=0.3) +boundary(ax, [(0, 0), (W, 0), (W, H), (0, H)]) +inflow_profile(ax, 0.0, 0.0, H, 1.5, side="left", scale=0.12) +inflow_profile(ax, W, 0.0, H, 1.5, side="right", scale=0.12) +label(ax, -0.26, H / 2, "inlet", rot=90, color=VELOCITY) +label(ax, W + 0.26, H / 2, "outlet", rot=270, color=VELOCITY) +label(ax, W / 2, H + 0.08, "no-slip"); label(ax, W / 2, -0.08, "no-slip") +axes_glyph(ax, (-0.32, -0.18), 0.1) +ax.set_xlim(-0.36, W + 0.34); ax.set_ylim(-0.22, H + 0.2) +save(fig, os.path.join(OUT, "mesh_channel_box")) +print("wrote", OUT) diff --git a/.claude/skills/uw-line-diagrams/examples/mesh_dfg_cylinder_1_20.png b/.claude/skills/uw-line-diagrams/examples/mesh_dfg_cylinder_1_20.png new file mode 100644 index 000000000..6e65748c7 Binary files /dev/null and b/.claude/skills/uw-line-diagrams/examples/mesh_dfg_cylinder_1_20.png differ diff --git a/.claude/skills/uw-line-diagrams/line-drawing-conventions.md b/.claude/skills/uw-line-diagrams/line-drawing-conventions.md new file mode 100644 index 000000000..aaca05887 --- /dev/null +++ b/.claude/skills/uw-line-diagrams/line-drawing-conventions.md @@ -0,0 +1,194 @@ +# Line Drawings for Articles: Conventions and a Practical Recipe + +A working reference for engineering-style line diagrams (with a fluid-dynamics slant), covering graphical conventions, production specifications, and tooling. + +--- + +## 1. Line hierarchy: weight and style carry meaning + +The formal lineage is ISO 128 ("Basic conventions for lines"), which fixes a small vocabulary of basic line types — continuous, dashed, dashed-spaced, long-dashed dotted, long-dashed double-dotted, dotted, etc. — together with rules for leader lines ([ISO 128-2:2022](https://cdn.standards.iteh.ai/samples/83355/10bb39d36fc34caeb80ecd25347ddb0c/ISO-128-2-2022.pdf)). The American counterpart is ASME Y14.2, "Line Conventions and Lettering" ([ASME](https://www.asme.org/codes-standards/find-codes-standards/y14-2-line-conventions-lettering)). Journals do not enforce these standards on authors, but textbook line drawings inherit their logic: **the reader should be able to classify any line by weight and dash pattern alone.** + +A robust three-weight, four-style scheme: + +| Role | Line style | Typical weight | +|---|---|---| +| Physical boundaries: walls, bodies, solid surfaces | Solid | Heavy (1–1.5 pt) | +| Fluid interfaces, free surfaces, geometry that matters | Solid | Medium (0.5–1 pt) | +| Streamlines, contour lines, construction/reference geometry | Solid, thin | Light (0.25–0.5 pt) | +| Hidden or idealized geometry; streamlines shown "behind" a body | Dashed | Medium | +| Control-volume boundary; computational domain | Dashed or dash-dot, closed curve | Medium | +| Centre lines, axes of symmetry | Long-dash-dot (the classic "centre line") | Light–medium | +| Dimension/leader lines | Thin solid with arrowheads or dots | Light | + +Rules of thumb: + +- Keep at most **three distinct weights**; anything finer is read as noise after reduction. +- Use dash patterns **consistently within one paper** — a dash-dot line should mean the same thing in Figure 2 as in Figure 12. +- Distinguish line *roles* by style, not by colour alone (see §4). + +## 2. Fluid-dynamics conventions specifically + +There is no ISO-style formal standard for fluid schematics; the conventions below are the strong disciplinary habits distilled from standard textbooks (White, Kundu & Cohen, Panton, Bar-Meir) and lecture notes: + +**Streamlines vs vectors vs trajectories.** Streamlines are thin, continuous curves tangent to the local velocity, drawn sparsely and given arrowheads only occasionally — a wall-to-wall thicket of arrowheads reads as noise. Velocity *vectors* are straight arrows anchored at points; if their lengths are not proportional to a real scale, say "schematic" in the caption. Keep the distinction between streamline, pathline, and streakline visually honest (dashed is often used for pathlines/trajectories). + +**Arrow classes.** Give each physical quantity its own arrow treatment. One worked example worth copying comes from Bar-Meir's *Basics of Fluid Mechanics*, which applies a consistent scheme across all its figures: blue for motion (velocity), green for forces/pressure/torque, black for dimensions and distances, red for energy/heat/work ([Basics of Fluid Mechanics](https://madar-ju.com/storage/images/files/file_1738976438FOFYp.pdf)) — a textbook habit rather than a formal standard, but exactly the kind of consistency reviewers notice. Whatever scheme you choose, forces and fluxes should be drawn heavier than streamlines, and every arrow class should be legible in grayscale. + +**Control volumes.** A closed dashed or dash-dot boundary, with mass/momentum flux arrows crossing it (in and out) and outward normals marked where the argument depends on them. The control surface should be *precisely defined* — an unambiguous closed surface fixed in a stated frame ([Caltech Fluidbook](http://brennen.caltech.edu/fluidbook/basicfluiddynamics/massconservation/controlvolume.pdf)). + +**Boundary layers and thin features.** Exaggerate the thickness and say so ("boundary-layer thickness exaggerated for clarity"); never let a reader mistake the drawing for a scale rendition. + +**Inlets/outlets.** Arrows crossing the domain boundary, labelled with the relevant quantities — \(u\), \(Q\), \(\dot m\), \(p\), \(T\), Re — so the schematic and the governing equations line up term by term. + +**Environment.** Show coordinate axes, gravity \(\mathbf g\), rotation, and section planes (with section arrows, as in ISO/ASME sectioning) explicitly. A reader should never have to infer the frame. + +**Texture.** Use hatching (45° thin lines) for solids/cross-sections, and leave fluid regions untextured or lightly tinted — the classic engineering-drawing convention that makes walls read as walls. + +## 3. Production specifications (what journals actually check) + +Draw the figure **vector** (PDF/EPS, or SVG via Inkscape) at the journal's final column width. Key numbers, current as of 2026 — these are common publisher targets (Nature, Elsevier, PLOS, ASME, PNAS, MAA), not universal rules; the destination journal's own instructions always win: + +| Parameter | Specification | Source | +|---|---|---| +| Line weight | 0.25–1 pt at final size (Nature); below 0.25 pt may vanish in print | [Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures) | +| Line weight | 0.5–1.5 pt; below 0.5 pt reproduces poorly (ASME proceedings) | [ASME](https://www.asme.org/publications-submissions/proceedings/formatting-the-paper/dealing-with-graphics) | +| Line weight | ≥ 0.5 pt; note that TikZ's default stroke (~0.3–0.4 pt) is "printable but very thin" | [MAA figure instructions](https://maa.org/wp-content/uploads/2025/10/Revised_Figure_Instructions2025.pdf) | +| Text size after reduction | 5–7 pt (Nature), 6 pt min (ASME, Elsevier subscripts), 8 pt min (PLOS) | [Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures), [ASME](https://www.asme.org/publications-submissions/proceedings/formatting-the-paper/dealing-with-graphics) | +| Column widths | 89 mm (Nature) / 90 mm (Elsevier) single; 183–190 mm full width | [Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures) | +| Formats | Vector (EPS/PDF) for line art, schematics, graphs; avoid low-resolution PNG exports of drawn content | [Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures) | +| Raster fallback | If line art must be rasterized: 1000 dpi (Elsevier), 1000–1200 ppi (PNAS) | [Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures), [PNAS](https://www.pnas.org/pb-assets/authors/digitalart-1675347574760.pdf) | +| Fonts | One sans-serif (Helvetica/Arial) across all figures; embedded; outlined only as the last step | [Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures) | +| Grayscale | Check the grayscale conversion before submission; print proceedings are often B&W | [ASME](https://www.asme.org/publications-submissions/proceedings/formatting-the-paper/dealing-with-graphics) | + +Practical habits: + +- **Design at final size.** Draw at exactly 90 mm (or 183 mm) wide, then print at 100% and inspect on paper — screens flatter thin lines ([Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures)). +- **Direct labels beat legends.** Label \(u\), \(p\), \(\mathbf F\) on the drawing with leader lines; reserve the caption for what cannot be drawn. +- **Match the manuscript's math.** Use the same italic/upright conventions for symbols in figures as in the text (\(u\) italic for variables, upright for units/operators). +- **Avoid 3D perspective** unless the geometry demands it; textbook style is orthographic or mildly oblique schematic. +- Every arrow, symbol, and abbreviation must be explained — ICMJE and most publishers require the figure to be self-explanatory from the legend ([Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures)). + +## 4. Colour, when you use it + +- Colour must be a *secondary* channel: every colour-coded distinction needs a redundant encoding (line style, dash pattern, direct label) so the figure survives grayscale and colour-vision deficiency (~8% of men) ([Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures)). +- Never encode data in red-vs-green alone; magenta/green or blue/orange are the standard safe pairs. +- For continuous fields, use perceptually uniform colormaps (Crameri's scientific colour maps), which keep ordering under grayscale conversion ([Scientific Figure](https://scientificfigure.com/blog/how-to-make-scientific-figures)). +- In a line *diagram* (as opposed to a data plot), restrained flat tints for fluid/solid regions plus black linework age best — this is the classic textbook look. + +## 5. Toolchain: LaTeX/TikZ and Typst/CeTZ + +Both ecosystems below express the same conventions; pick the one matching your manuscript and keep the style definitions in a shared file so every figure inherits them. + +**LaTeX / TikZ-PGF.** The gold standard for reproducible, journal-quality schematics; fonts and math automatically match the manuscript. Izaak Neutelings' TikZ.net hosts a large, well-crafted **Fluid Dynamics category** (laminar/turbulent flow, Bernoulli/Venturi, buoyancy, surface tension...) with downloadable `.tex` sources and Overleaf links — an excellent starting point to steal styles from ([tikz.net fluid dynamics](https://tikz.net/fluid_dynamics_laminar/)). Note the house conventions in those figures: `very thick` round-capped vectors for velocity, distinct heavy styles for forces, LaTeX arrowheads, dashed construction lines, and light flat tints for water. + +**Typst / CeTZ.** [CeTZ](https://github.com/cetz-package/cetz) ("CeTZ, ein Typst Zeichenpaket") is the TikZ-equivalent drawing package for Typst — a TikZ/Processing-inspired API with relative coordinates, TikZ-style anchors, and an auto-resizing canvas ([GitHub](https://github.com/cetz-package/cetz)). It maps naturally onto every convention in this guide: + +- **Line weights** use plain Typst strokes: `stroke: 1.2pt`, or `stroke: (paint: red, thickness: 1pt, cap: "round")` ([CeTZ styling docs](https://cetz-package.github.io/docs/basics/styling/)). +- **Dash patterns** use Typst's predefined names — `"dashed"`, `"dotted"`, `"dash-dotted"` (plus `densely-`/`loosely-` variants) or explicit arrays like `(10pt, 5pt, "dot", 5pt)` for centre-line and control-volume styles ([Typst stroke docs](https://typst.app/docs/reference/visualize/stroke/)). +- **Arrowheads** are *marks*: `mark: (end: "stealth", fill: black, scale: 0.8)`, with shorthands `">"` (triangle) and `">>"` (stealth), and options for `harpoon`, `flip`, `reverse`, `length`, `width` — a direct analogue of TikZ's `arrows.meta` ([CeTZ marks docs](https://cetz-package.github.io/docs/basics/marks/)). +- **Global styles** via `set-style(...)`, the counterpart of `\tikzset` ([CeTZ styling docs](https://cetz-package.github.io/docs/basics/styling/)). +- **Hatching** needs one extra package: build a diagonal-line pattern with `modpattern` (or Typst's `tiling`) and pass it as `fill:` ([CeTZ issue #805](https://github.com/cetz-package/cetz/issues/805), [Typst forum](https://forum.typst.app/t/is-there-a-way-to-do-hatched-in-filling-like-this-in-typst-cetz/2467)). +- Gotcha: since CeTZ 0.4.2 the line's stroke styling (e.g. its dash pattern) is also applied to its arrowhead; override with `mark: (..., stroke: (dash: none))` ([Typst forum](https://forum.typst.app/t/how-to-get-different-stroke-styles-for-a-line-and-its-mark-in-cetz-0-4-2/8264)). +- For node-and-arrow diagrams (flow charts, force diagrams), [Fletcher](https://typst.app/universe/package/fletcher/) builds on CeTZ and is the standard choice ([Typst Universe](https://typst.app/universe/package/tiptoe/)). + +**A shared gallery for both.** [janosh/diagrams](https://github.com/janosh/diagrams) is a browsable collection of 140+ MIT-licensed scientific diagrams in physics, chemistry and ML, each downloadable as PDF/SVG/PNG **with its source in either TikZ `.tex` or CeTZ `.typ` form** — ideal for seeing the same conventions expressed in both languages ([GitHub](https://github.com/janosh/diagrams)). + +**Inkscape.** Best for interactive vector drawing and cleanup; exports PDF/EPS; keep the SVG as source. Use for figures that start as sketches rather than code. + +**matplotlib/Julia.** Right tool when geometry is *computed* (streamline fields from actual model output); export PDF (vector) rather than PNG; strip chartjunk and re-letter in the manuscript font. + +**Recommended workflow for a Markdown/Typst/Myst pipeline:** + +1. Keep a `figures/` directory with *source* (`.tex`/`.svg`/`.typ`) and a build step exporting `.pdf` (LaTeX) or `.svg` (web/Myst). +2. Define one shared style file (line weights, arrow styles, colours, font sizes) imported by every figure — consistency across figures is what publishers and reviewers actually notice. +3. Version figures with the manuscript; regenerate from source at submission time so the PDF and the source never drift. + +### A minimal TikZ style block to start from (LaTeX) + +```latex +% \usetikzlibrary{arrows.meta,patterns} +% line weights: 0.5pt / 0.8pt / 1.2pt at 90mm column width +\tikzset{ + boundary/.style = {line width=1.2pt}, % walls, bodies + interface/.style = {line width=0.8pt}, % free surface + streamline/.style = {line width=0.5pt}, % flow lines + hidden/.style = {line width=0.8pt, dashed}, + cvline/.style = {line width=0.8pt, dash pattern=on 5pt off 2pt on 1pt off 2pt}, + axis/.style = {line width=0.5pt, dash pattern=on 8pt off 2pt on 1pt off 2pt}, + vvec/.style = {-{Latex[length=3.5pt,width=2.5pt]}, line width=1.0pt, line cap=round}, + force/.style = {-{Latex[length=4pt,width=3pt]}, line width=1.4pt, line cap=round}, + dim/.style = {{Latex[length=3pt,width=2.5pt]}-{Latex[length=3pt,width=2.5pt]}, + line width=0.5pt}, + hatch/.style = {pattern=north east lines}, % 45-degree hatching +} +``` + +### A minimal CeTZ style block to start from (Typst) + +```typst +#import "@preview/cetz:0.5.2": canvas +#import "@preview/modpattern:0.1.0": modpattern + +// shared figure styles — same roles as the TikZ block above +#let styles = ( + boundary: (stroke: 1.2pt), // walls, bodies + interface: (stroke: 0.8pt), // free surface + streamline:(stroke: 0.5pt), // flow lines + hidden: (stroke: (thickness: 0.8pt, dash: "dashed")), + cvline: (stroke: (thickness: 0.8pt, dash: (5pt, 2pt, 1pt, 2pt))), + axis: (stroke: (thickness: 0.5pt, dash: (8pt, 2pt, 1pt, 2pt))), + vvec: (stroke: (thickness: 1.0pt, cap: "round"), + mark: (end: "stealth", fill: black, scale: 0.8)), + force: (stroke: (thickness: 1.4pt, cap: "round"), + mark: (end: "stealth", fill: black, scale: 1.0)), + dim: (stroke: 0.5pt, + mark: (symbol: ">", fill: black, scale: 0.6)), // both ends +) + +// 45-degree hatching for solids/cross-sections +#let hatch = modpattern((3pt, 3pt), + std.line(start: (0%, 0%), end: (100%, 100%), stroke: 0.4pt)) + +// usage: everything inside the canvas inherits Typst's text/math fonts +#canvas(length: 1cm, { + import cetz.draw: * + rect((0, 0), (4, 1), ..styles.boundary, fill: hatch) // hatched wall + line((0.5, 1.2), (2.5, 1.2), ..styles.vvec) // velocity vector + content((2.6, 1.2), $u$) // label in math mode +}) +``` + +The two blocks are deliberately parallel: `boundary`/`interface`/`streamline` weights, the dash patterns for hidden lines, control volumes, and centre lines, and the arrow classes (`vvec`, `force`, `dim`) match one-to-one, so a figure can be prototyped in either system and translated mechanically. + +## 6. Pre-submission checklist + +- [ ] Drawn as vector (PDF/EPS/SVG), not raster; text not outlined until the final export +- [ ] Designed at final column width (90 mm / 183 mm); printed and inspected at 100% +- [ ] All line weights ≥ 0.5 pt at final size; at most three weights +- [ ] All text ≥ 6 pt (journal-dependent; 8 pt is safer) after reduction; one font throughout +- [ ] Every line role distinguishable in grayscale and without colour +- [ ] Streamlines/vectors/forces/dimensions each have a distinct arrow style; all explained in the caption +- [ ] Control volume closed and unambiguous; axes, gravity, normals shown +- [ ] Exaggerations (boundary layers, aspect ratios) declared in the caption +- [ ] Symbols and notation match the manuscript text +- [ ] Source files under version control; figure regenerable by a single build command +- [ ] Destination journal's current figure instructions checked — its spec wins over any general guidance here + +--- + +### Sources + +- [ISO 128-2:2022 — Basic conventions for lines](https://cdn.standards.iteh.ai/samples/83355/10bb39d36fc34caeb80ecd25347ddb0c/ISO-128-2-2022.pdf) +- [ASME Y14.2 — Line Conventions and Lettering](https://www.asme.org/codes-standards/find-codes-standards/y14-2-line-conventions-lettering) +- [ASME proceedings — Dealing with Graphics](https://www.asme.org/publications-submissions/proceedings/formatting-the-paper/dealing-with-graphics) +- [MAA Figure Instructions](https://maa.org/wp-content/uploads/2025/10/Revised_Figure_Instructions2025.pdf) +- [Scientific Figure — How to Make Scientific Figures for Journals (2026)](https://scientificfigure.com/blog/how-to-make-scientific-figures) +- [PNAS Digital Art Guidelines](https://www.pnas.org/pb-assets/authors/digitalart-1675347574760.pdf) +- [Bar-Meir, Basics of Fluid Mechanics — figure colour scheme](https://madar-ju.com/storage/images/files/file_1738976438FOFYp.pdf) +- [Caltech Fluidbook — Control Volume](http://brennen.caltech.edu/fluidbook/basicfluiddynamics/massconservation/controlvolume.pdf) +- [TikZ.net Fluid Dynamics examples (Izaak Neutelings)](https://tikz.net/fluid_dynamics_laminar/) +- [CeTZ — a Typst drawing package (GitHub)](https://github.com/cetz-package/cetz) +- [CeTZ documentation — styling](https://cetz-package.github.io/docs/basics/styling/) and [marks](https://cetz-package.github.io/docs/basics/marks/) +- [Typst stroke reference — dash patterns](https://typst.app/docs/reference/visualize/stroke/) +- [modpattern — hatched fills for CeTZ](https://github.com/cetz-package/cetz/issues/805) +- [janosh/diagrams — TikZ and CeTZ scientific diagram gallery](https://github.com/janosh/diagrams) diff --git a/.claude/skills/uw-line-diagrams/uwfig.py b/.claude/skills/uw-line-diagrams/uwfig.py new file mode 100644 index 000000000..f7b7b5df0 --- /dev/null +++ b/.claude/skills/uw-line-diagrams/uwfig.py @@ -0,0 +1,250 @@ +"""House style for underworld3 line diagrams (matplotlib). + +One module, imported by every schematic (docstring figures, notebook figures, +benchmark meshes, article figures) so that a wall, a streamline, a velocity +arrow or a control-volume boundary looks the same in all of them. The roles +and weights follow ``line-drawing-conventions.md`` in this skill; the CeTZ +block ``uwfig.typ`` carries the same roles for Typst figures. + +Usage:: + + from uwfig import * # or: import uwfig as F + fig, ax = figure(width_mm=183, aspect=0.38) + draw_mesh(ax, mesh) # thin light triangles + boundary(ax, [(0, 0), (W, 0), (W, H), (0, H), (0, 0)]) + inflow_profile(ax, x0=0.0, y0=0.0, y1=H, umax=1.5, side="left") + label(ax, W / 2, H + 0.07, "no-slip") + save(fig, "figures/mesh_channel") # .pdf + .svg + .png + +Everything is a thin wrapper over matplotlib: pass extra keyword arguments +through to override a role for one element, never redefine a role locally. +""" +from __future__ import annotations +import os +import numpy as np +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.patches import Circle, Rectangle, Polygon + +__all__ = [ + "INK", "MESH", "VELOCITY", "FORCE", "ENERGY", "DIMENSION", "TINT_FLUID", "TINT_SOLID", + "LINE", "ARROW", "line_style", "arrow_style", + "figure", "mesh_triangles", "draw_mesh", "boundary", "circle_body", "wall", + "arrow", "flow_profile", "inflow_profile", "uniform_arrows", "dimension", "axes_glyph", "label", "leader", "save", +] + +# --------------------------------------------------------------------------- colour tokens +# Colour is a SECONDARY channel: every role is also fixed by weight and dash pattern, and +# each arrow class has its own head and weight, so a grayscale print keeps the meaning. +INK = "#1f2a33" # walls, bodies, text, dimensions +MESH = "#9aa3aa" # mesh edges: light, never heavier than 0.35 pt +VELOCITY = "#1b6f6a" # motion: velocity vectors, inflow/outflow profiles, streamlines +FORCE = "#a1522a" # forces, pressure, tractions, applied stress +ENERGY = "#6b3d8f" # heat, work, energy flux +DIMENSION = INK +TINT_FLUID = "#eef3f6" # optional flat tint for a fluid region +TINT_SOLID = "#e4e0da" # optional flat tint for a solid region (with hatching) + +# --------------------------------------------------------------------------- line roles +# weights in points AT FINAL SIZE (90 mm single column / 183 mm double column); three +# weights only: 1.2 (physical boundary), 0.8 (interface, hidden, control volume), 0.5 (thin) +LINE = { + "boundary": dict(lw=1.2, ls="-", color=INK), # walls, bodies, solid surfaces + "interface": dict(lw=0.8, ls="-", color=INK), # free surface, material interface + "streamline": dict(lw=0.5, ls="-", color=VELOCITY), # streamlines, contours, construction + "hidden": dict(lw=0.8, ls=(0, (4, 2)), color=INK), # hidden / idealised geometry + "cv": dict(lw=0.8, ls=(0, (5, 2, 1, 2)), color=INK), # control volume, computational domain + "axis": dict(lw=0.5, ls=(0, (8, 2, 1, 2)), color=INK), # centre line, axis of symmetry + "mesh": dict(lw=0.25, ls="-", color=MESH), # mesh edges + "leader": dict(lw=0.5, ls="-", color=INK), # label leaders +} + +# --------------------------------------------------------------------------- arrow classes +# ``mutation_scale`` is the head size in points; the head grows with the line weight. +ARROW = { + "velocity": dict(arrowstyle="-|>", lw=0.9, mutation_scale=7, color=VELOCITY), + "force": dict(arrowstyle="-|>", lw=1.3, mutation_scale=9, color=FORCE), + "energy": dict(arrowstyle="-|>", lw=1.1, mutation_scale=8, color=ENERGY), + "dimension": dict(arrowstyle="<|-|>", lw=0.5, mutation_scale=5, color=DIMENSION), + "leader": dict(arrowstyle="-", lw=0.5, color=INK), +} + + +def line_style(role: str, **override) -> dict: + """Keyword arguments for ``ax.plot`` / patches in one line role.""" + return {**LINE[role], **override} + + +def arrow_style(kind: str, **override) -> dict: + """``arrowprops`` for ``ax.annotate`` in one arrow class.""" + return {**ARROW[kind], **override} + + +# --------------------------------------------------------------------------- rcParams +# One sans-serif face for every figure, text kept as text in PDF and SVG (editable, searchable), +# 8 pt at final size so that nothing falls below 6 pt after a modest reduction. +matplotlib.rcParams.update({ + "font.family": "sans-serif", + "font.sans-serif": ["Helvetica", "Arial", "Helvetica Neue", "Liberation Sans", "DejaVu Sans"], + "font.size": 8, + "mathtext.fontset": "dejavusans", + "axes.linewidth": 0.6, + "pdf.fonttype": 42, + "ps.fonttype": 42, + "svg.fonttype": "none", + "savefig.bbox": "tight", + "savefig.pad_inches": 0.02, +}) + +MM = 1 / 25.4 + + +def figure(width_mm: float = 90.0, aspect: float = 0.6, **kw): + """A figure drawn at the final column width: 90 mm single, 183 mm double. + + ``aspect`` is height / width. The axes fill the figure, keep equal aspect and hide the + frame: a schematic has no axes of its own. + """ + fig = plt.figure(figsize=(width_mm * MM, width_mm * aspect * MM), **kw) + ax = fig.add_axes([0, 0, 1, 1]) + ax.set_aspect("equal") + ax.axis("off") + return fig, ax + + +# --------------------------------------------------------------------------- meshes +def mesh_triangles(mesh): + """(points[:, :2], triangles) of an underworld3 mesh, through the PyVista bridge.""" + from underworld3 import visualisation as vis + pv = vis.mesh_to_pv_mesh(mesh) + if hasattr(pv, "cells_dict") and 5 in pv.cells_dict: # VTK_TRIANGLE + return np.asarray(pv.points)[:, :2], np.asarray(pv.cells_dict[5]) + surf = pv.extract_surface().triangulate() + return np.asarray(surf.points)[:, :2], np.asarray(surf.faces).reshape(-1, 4)[:, 1:] + + +def draw_mesh(ax, mesh_or_tris, **override): + """Thin, light mesh edges. Pass a mesh or a (points, triangles) pair. + + The mesh is context, not the subject: keep it under the boundary lines and never + heavier than the ``mesh`` role. Zoom insets may raise ``lw`` to 0.35. + """ + pts, tris = mesh_or_tris if isinstance(mesh_or_tris, tuple) else mesh_triangles(mesh_or_tris) + ax.triplot(pts[:, 0], pts[:, 1], tris, **line_style("mesh", **override)) + return pts, tris + + +# --------------------------------------------------------------------------- geometry +def boundary(ax, xy, role: str = "boundary", closed: bool = True, fill=None, **override): + """A polyline in a line role (``boundary``, ``interface``, ``hidden``, ``cv``, ``axis``).""" + xy = np.asarray(xy, float) + if closed and not np.allclose(xy[0], xy[-1]): + xy = np.vstack([xy, xy[:1]]) + st = line_style(role, **override) + if fill is not None: + ax.add_patch(Polygon(xy, closed=True, fc=fill, ec="none", zorder=0)) + ax.plot(xy[:, 0], xy[:, 1], **st) + + +def circle_body(ax, centre, radius, role: str = "boundary", fill=None, **override): + """A circular body (cylinder cross-section) drawn in a line role.""" + st = line_style(role, **override) + ax.add_patch(Circle(centre, radius, fill=fill is not None, fc=fill or "none", + ec=st["color"], lw=st["lw"], ls=st["ls"])) + + +def wall(ax, xy0, width, height, hatch: str = "///", **override): + """A hatched solid wall (45 degree thin lines): the engineering-drawing convention.""" + st = line_style("boundary", **override) + ax.add_patch(Rectangle(xy0, width, height, fc=TINT_SOLID, ec=st["color"], lw=st["lw"], + hatch=hatch, zorder=1)) + + +# --------------------------------------------------------------------------- arrows +def arrow(ax, tail, head, kind: str = "velocity", **override): + """One arrow of a class: ``velocity``, ``force``, ``energy``, ``dimension``.""" + ax.annotate("", xy=head, xytext=tail, arrowprops=arrow_style(kind, **override)) + + +def flow_profile(ax, p0, p1, umax, inward, into: bool = True, n: int = 9, scale: float = 1.0, + kind: str = "velocity", **override): + """A parabolic velocity profile on the edge p0 -> p1, drawn outside the domain. + + ``inward`` is a vector pointing from the edge into the domain. The parabola bulges + away from the domain; the arrows follow the flow: from outside onto the edge for an + inlet (``into=True``), from the edge outwards for an outlet (``into=False``). + ``scale`` converts velocity to drawing units. Draw the profile instead of writing + it: the parabola says "Poiseuille" on its own. + """ + p0, p1 = np.asarray(p0, float), np.asarray(p1, float) + nrm = -np.asarray(inward, float); nrm = nrm / (np.linalg.norm(nrm) or 1.0) # outward unit normal + s = np.linspace(0, 1, 200) + u = 4 * umax * s * (1 - s) * scale + pts = p0 + np.outer(s, p1 - p0) + np.outer(u, nrm) + st = arrow_style(kind, **override) + ax.plot(pts[:, 0], pts[:, 1], color=st["color"], lw=st["lw"]) + for si in np.linspace(0, 1, n + 2)[1:-1]: + e = p0 + si * (p1 - p0); o = e + 4 * umax * si * (1 - si) * scale * nrm + tail, head = (o, e) if into else (e, o) + arrow(ax, tail, head, kind, lw=st["lw"] * 0.8, **override) + + +def inflow_profile(ax, x0, y0, y1, umax, side: str = "left", **kw): + """``flow_profile`` on a vertical edge x = x0: ``side="left"`` is an inlet on the left + of the domain, ``side="right"`` an outlet on the right.""" + if side == "left": + flow_profile(ax, (x0, y0), (x0, y1), umax, inward=(1, 0), into=True, **kw) + else: + flow_profile(ax, (x0, y0), (x0, y1), umax, inward=(-1, 0), into=False, **kw) + + +def uniform_arrows(ax, x0, x1, ys, kind: str = "force", **override): + """A row of equal arrows from x0 to x1 at heights ``ys`` (a body force, an applied stress).""" + for y in ys: + arrow(ax, (x0, y), (x1, y), kind, **override) + + +def dimension(ax, p0, p1, text: str = "", offset: float = 0.0, **override): + """A double-headed dimension line between p0 and p1, shifted normal to itself by ``offset``.""" + p0, p1 = np.asarray(p0, float), np.asarray(p1, float) + d = p1 - p0; nrm = np.array([-d[1], d[0]]) / (np.linalg.norm(d) or 1.0) + q0, q1 = p0 + offset * nrm, p1 + offset * nrm + arrow(ax, q0, q1, "dimension", **override) + if text: + m = (q0 + q1) / 2 + 0.02 * nrm * np.sign(offset or 1.0) * np.hypot(*d) + label(ax, m[0], m[1], text) + + +def axes_glyph(ax, origin, length: float, labels=("$x$", "$y$")): + """A small coordinate frame so the reader never has to infer it.""" + ox, oy = origin + for (dx, dy), t, ha, va in (((length, 0), labels[0], "left", "center"), + ((0, length), labels[1], "center", "bottom")): + ax.annotate("", xy=(ox + dx, oy + dy), xytext=(ox, oy), + arrowprops=dict(arrowstyle="-|>", lw=0.6, mutation_scale=6, color=INK)) + ax.text(ox + dx * 1.15, oy + dy * 1.15, t, ha=ha, va=va, fontsize=8, color=INK) + + +# --------------------------------------------------------------------------- text +def label(ax, x, y, text, ha="center", va="center", rot=0, color=INK, fontsize=8, box=True, **kw): + """A direct label on the drawing. Symbols in mathtext (``$u$``); no caption material.""" + bbox = dict(boxstyle="round,pad=0.25", fc="white", ec="none", alpha=0.85) if box else None + ax.text(x, y, text, ha=ha, va=va, rotation=rot, color=color, fontsize=fontsize, bbox=bbox, **kw) + + +def leader(ax, text_xy, target_xy, text: str = "", ha="left", color=INK): + """A label joined to its feature by a thin leader line.""" + ax.annotate("", xy=target_xy, xytext=text_xy, arrowprops=arrow_style("leader", color=color)) + if text: + label(ax, text_xy[0], text_xy[1], text, ha=ha, color=color) + + +# --------------------------------------------------------------------------- output +def save(fig, stem: str, formats=("pdf", "svg", "png"), dpi: int = 300, close: bool = True): + """Write the figure as vector (PDF, SVG) plus a PNG at ``dpi``. ``stem`` has no extension.""" + os.makedirs(os.path.dirname(os.path.abspath(stem)), exist_ok=True) + for ext in formats: + fig.savefig(f"{stem}.{ext}", dpi=dpi if ext == "png" else None) + if close: + plt.close(fig) + return [f"{stem}.{ext}" for ext in formats] diff --git a/.claude/skills/uw-line-diagrams/uwfig.typ b/.claude/skills/uw-line-diagrams/uwfig.typ new file mode 100644 index 000000000..a652fe048 --- /dev/null +++ b/.claude/skills/uw-line-diagrams/uwfig.typ @@ -0,0 +1,68 @@ +// House style for underworld3 line diagrams (Typst + CeTZ). +// The same roles, weights, dash patterns and colours as uwfig.py, so a figure +// can be prototyped in matplotlib and redrawn here (or the reverse) without +// changing its look. Import from a figure with +// #import "uwfig.typ": * +// and spread a role into a draw call: line(a, b, ..styles.boundary) +// +// Versions: written against cetz 0.3.4 / Typst 0.13 (the versions the +// cetz-figures skill compiles with); check the marks syntax on a newer cetz. + +// ---- colour tokens (secondary channel: every role also has a weight and dash) +#let ink = rgb("#1f2a33") // walls, bodies, text, dimensions +#let mesh-grey = rgb("#9aa3aa") // mesh edges +#let velocity = rgb("#1b6f6a") // motion: velocity vectors, inflow profiles, streamlines +#let force = rgb("#a1522a") // forces, pressure, tractions, applied stress +#let energy = rgb("#6b3d8f") // heat, work, energy flux +#let tint-fluid = rgb("#eef3f6") +#let tint-solid = rgb("#e4e0da") + +// ---- line roles: three weights (1.2 / 0.8 / 0.5 pt at final column width) +#let styles = ( + boundary: (stroke: (paint: ink, thickness: 1.2pt)), // walls, bodies + interface: (stroke: (paint: ink, thickness: 0.8pt)), // free surface + streamline: (stroke: (paint: velocity, thickness: 0.5pt)), // flow lines + hidden: (stroke: (paint: ink, thickness: 0.8pt, dash: "dashed")), + cvline: (stroke: (paint: ink, thickness: 0.8pt, dash: (5pt, 2pt, 1pt, 2pt))), // control volume + axis: (stroke: (paint: ink, thickness: 0.5pt, dash: (8pt, 2pt, 1pt, 2pt))), // centre line + mesh: (stroke: (paint: mesh-grey, thickness: 0.25pt)), + leader: (stroke: (paint: ink, thickness: 0.5pt)), + // ---- arrow classes: each physical quantity has its own head and weight + vvec: (stroke: (paint: velocity, thickness: 0.9pt, cap: "round"), + mark: (end: "stealth", fill: velocity, scale: 0.7, stroke: (dash: none))), + fvec: (stroke: (paint: force, thickness: 1.3pt, cap: "round"), + mark: (end: "stealth", fill: force, scale: 0.9, stroke: (dash: none))), + evec: (stroke: (paint: energy, thickness: 1.1pt, cap: "round"), + mark: (end: "stealth", fill: energy, scale: 0.8, stroke: (dash: none))), + dim: (stroke: (paint: ink, thickness: 0.5pt), + mark: (symbol: ">", fill: ink, scale: 0.5)), // both ends +) + +// ---- 45 degree hatching for solids / cross-sections (needs modpattern) +// #import "@preview/modpattern:0.1.0": modpattern +// #let hatch = modpattern((3pt, 3pt), +// std.line(start: (0%, 0%), end: (100%, 100%), stroke: (paint: ink, thickness: 0.4pt))) + +// ---- a parabolic inflow profile outside a vertical edge, arrows following the flow +// side: "left" = inlet (profile to the left of x0, arrows into the domain) +// "right" = outlet (profile to the right of x0, arrows out of the domain) +#let inflow-profile(x0, y0, y1, umax, side: "left", n: 9, scale: 1.0) = { + import cetz.draw: * + let h = y1 - y0 + let u(y) = 4 * umax * (y - y0) * (y1 - y) / (h * h) * scale + let sgn = if side == "left" { -1 } else { 1 } + let pts = range(0, 41).map(i => { let y = y0 + h * i / 40; (x0 + sgn * u(y), y) }) + line(..pts, stroke: (paint: velocity, thickness: 0.9pt)) + for i in range(1, n + 1) { + let y = y0 + h * i / (n + 1) + if side == "left" { line((x0 - u(y), y), (x0, y), ..styles.vvec) } + else { line((x0, y), (x0 + u(y), y), ..styles.vvec) } + } +} + +// ---- a direct label with a white backing so it reads over mesh lines +#let label(pos, body, anchor: "center") = { + import cetz.draw: * + content(pos, box(fill: white.transparentize(15%), inset: 2pt, radius: 2pt, text(8pt, fill: ink, body)), + anchor: anchor) +} diff --git a/.claude/skills/uw-visualisation/SKILL.md b/.claude/skills/uw-visualisation/SKILL.md index 1a43f6ea5..1fcce638f 100644 --- a/.claude/skills/uw-visualisation/SKILL.md +++ b/.claude/skills/uw-visualisation/SKILL.md @@ -21,6 +21,9 @@ this even after seeing the legacy matplotlib renderer the run directory, comparison figures into the sim-dir root. - `pv.OFF_SCREEN = True` at import; finish with `pl.screenshot(path); pl.close()`. +Overlays drawn ON a render (a boundary, an arrow, a label) and every DRAWN +schematic follow the `uw-line-diagrams` skill. + ## The field+mesh pattern (copy this exactly) ```python