diff --git a/packages/preview/conic-toan/0.3.4/LICENSE b/packages/preview/conic-toan/0.3.4/LICENSE
new file mode 100644
index 0000000000..1f65d592fb
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Kim Thu
+
+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.
diff --git a/packages/preview/conic-toan/0.3.4/README.md b/packages/preview/conic-toan/0.3.4/README.md
new file mode 100644
index 0000000000..626c4a70e3
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/README.md
@@ -0,0 +1,2212 @@
+
conic-toan
+
+
+ Draw math figures, graphs and variation tables — and build exam papers.
+ A dependency-free Typst library for Vietnamese high-school mathematics.
+
+
+
+
+
+
+## English overview
+
+`conic-toan` is a pure Typst library — **no external packages, no CeTZ** — for
+authoring Vietnamese high-school mathematics material. Everything below is drawn
+by the library itself and compiles offline.
+
+**What it does**
+
+- **Plane and solid geometry.** Triangles with their notable points and circles
+ (incircle, circumcircle, orthocentre, excircles), pyramids, prisms, frustums,
+ cones, cylinders and spheres, with hidden edges dashed automatically.
+- **Function graphs.** Quadratic through rational functions, exponentials,
+ logarithms, trigonometry, conics, tangent lines, areas between curves and
+ feasible regions of linear systems. Labels carry *exact* values — a cubic's
+ extremum prints as a surd, not as `10.48`.
+- **Variation tables from coefficients.** `bbt-bac-ba(1, 0, -3, 1)` produces the
+ full table for *y = x³ − 3x + 1*, covering every discriminant case.
+- **Full worked solutions.** `khao-sat-ve-do-thi-ham-bac-ba(1, 0, -3, 1)` emits
+ the domain, derivative, limits, monotonicity, extrema, variation table and
+ graph — the entire textbook analysis from five numbers.
+- **Statistics for the 2018 curriculum.** Frequency tables, histograms, ogives,
+ bar charts, box plots and pie charts, plus fourteen summary statistics for raw
+ and grouped data.
+- **Solids of revolution.** A plane region, the rotation arrow, the resulting
+ solid and its volume integral, side by side.
+- **One source, three PDFs.** The same question file renders as a printable exam,
+ a solution key, or 16:9 lecture slides. Eight question types, automatic answer
+ keys and answer tables.
+
+Function and parameter names are in unaccented Vietnamese (`doan`, `duong-tron`,
+`bbt-bac-ba`), and the documentation below is in Vietnamese.
+
+**Quick start**
+
+```typst
+#import "@preview/conic-toan:0.3.4": *
+
+#do-thi-bac-ba(1, 0, -3, 1) // graph with exact extrema
+#bbt-bac-ba(1, 0, -3, 1) // matching variation table
+#hinh-chop-luc-giac-deu() // hexagonal pyramid
+```
+
+
+
+  |
+  |
+
+
+  |
+  |
+
+
+  |
+  |
+
+
+
+One question source, rendered as lecture slides and as an exam paper:
+
+
+ 
+
+
+
+Licensed under MIT. Full documentation in Vietnamese follows.
+
+---
+
+# Hệ thống bài giảng trình chiếu Toán THPT bằng Typst
+
+Thuần Typst, **không phụ thuộc package nào** — biên dịch offline được ngay.
+
+## Cài đặt
+
+```typst
+#import "@preview/conic-toan:0.3.4": *
+```
+
+Không cần tải gì thêm — Typst tự tải gói về khi biên dịch lần đầu.
+Mã nguồn và các file mẫu: https://github.com/Thutran1891/conic-toan
+
+## Yêu cầu & biên dịch
+
+- Typst **0.13 trở lên** (cài trên Windows: `winget install --id Typst.Typst`).
+- Biên dịch: `typst compile main.typ` — Xem trực tiếp khi soạn: `typst watch main.typ`.
+- Khuyến nghị dùng VS Code + extension **Tinymist Typst** để xem trước tức thì.
+
+## Cấu trúc
+
+```
+baigiang.typ ← import MỘT file này là dùng được tất cả
+main.typ ← bài giảng demo trình diễn toàn bộ tính năng
+lib/
+ slide.typ ← engine trình chiếu 16:9
+ ve.typ ← engine vẽ hình lõi (hệ toạ độ, điểm, đoạn, cung, góc…)
+ hinh-phang.typ ← hình học phẳng
+ hinh-khong-gian.typ ← hình học không gian
+ do-thi.typ ← đồ thị hàm số
+ oxyz-toan.typ ← TÍNH TOÁN trong Oxyz (vectơ, mặt phẳng, đường, mặt cầu)
+ bang.typ ← bảng biến thiên, bảng xét dấu
+```
+
+Tạo bài giảng mới: copy `main.typ` thành `bai-1.typ` rồi sửa nội dung.
+
+## 1. Trình chiếu
+
+```typst
+#import "@preview/conic-toan:0.3.4": *
+#show math.equation.where(block: false): it => math.display(it)
+
+#show: bai-giang.with(
+ tieu-de: [CHƯƠNG I. KHẢO SÁT HÀM SỐ],
+ tieu-de-ngan: [Chương I. Khảo sát hàm số], // tên bài rút gọn — LUÔN hiện
+ // trên dải đầu trang mọi slide (none => dùng nguyên tieu-de)
+ phu-de: [Tiết 1: Sự đồng biến, nghịch biến],
+ gv: "Kim Thu",
+ ngay: "12/07/2026",
+ lop: "Lớp 12A1", // (tuỳ chọn) hiện trên TRANG BÌA
+ mon: "Giải tích", // (tuỳ chọn) môn/chương hiện trên TRANG BÌA
+ logo: image("logo.png"), // (tuỳ chọn) logo trường — PHẢI bọc image(...)
+ // Đặt ảnh CÙNG thư mục file .typ này. KHÔNG
+ // truyền chuỗi "logo.png" (image() chạy trong
+ // package sẽ tìm ảnh cạnh package -> không thấy).
+ // none = không có logo.
+ kieu-bia: 1, // KIỂU TRANG BÌA — 1..5 hoặc tên:
+ // 1 "toi-gian" Tối giản & Thanh lịch
+ // 2 "tre-trung" Trẻ trung & Sáng tạo
+ // 3 "co-dien" Chuẩn mực Học thuật Cổ điển
+ // 4 "chuyen-nghiep" Chuyên nghiệp & Khoa học
+ // 5 "ky-thuat" Tiêu chuẩn Tài liệu Kỹ thuật
+ // Mỗi kiểu có TÔNG MÀU riêng; đặt mau-chinh/
+ // mau-nhan để GHI ĐÈ tông đó (đồng bộ cả bài).
+ mau-chinh: rgb("#0f4c81"), // đổi màu chủ đạo tuỳ ý (auto = theo kieu-bia)
+ nen: "kem", // nền slide: "trang" (mặc định) | "kem"
+ // | "xanh-nhat" | "luc-nhat" | "xam"
+ // hoặc màu TUỲ Ý, ví dụ các tông sáng đẹp:
+ // rgb("#fdf3e7") cam nhạt
+ // rgb("#fdf0f0") hồng nhạt
+ // rgb("#f6f0fb") tím nhạt
+ // rgb("#eefaf6") bạc hà
+ // rgb("#fbfbe8") vàng chanh nhạt
+ ti-le-chu: 1.0, // HỆ SỐ PHÓNG CỠ CHỮ THÂN NỘI DUNG (toàn file):
+ // 1.0 giữ nguyên; 1.1 to hơn 10%; 0.9 nhỏ đi 10%.
+ // Chỉ co giãn thân + khung (định nghĩa/ví dụ/lời
+ // giải…); thanh tiêu đề, header, footer giữ nguyên.
+ gian-dong: 1.0, // HỆ SỐ GIÃN DÒNG — khoảng cách giữa các dòng
+ // (toàn file): 1.0 = mốc mặc định (trình chiếu
+ // 0.62em · A4 bài giảng 0.6em · A4 đề thi 0.65em);
+ // 1.25 giãn thêm 25%; 0.9 thu lại 10%. TĂNG khi
+ // phân số / căn thức / chỉ số nhiều tầng làm hai
+ // dòng liền nhau dính (thậm chí chồng) vào nhau.
+ mau-cong-thuc: auto, // MÀU MỌI CÔNG THỨC trong $...$ (toàn file):
+ // auto (mặc định) = thừa kế màu chữ — thân đen,
+ // tiêu đề/bìa trắng. Đặt màu, vd rgb("#0f4c81"),
+ // để nhuộm TẤT CẢ công thức theo màu đó.
+)
+
+#muc[1. Định nghĩa] // slide chuyển phần
+#slide(tieu-de: [Khái niệm])[ ... ] // slide thường
+#trang-cam-on()
+```
+
+Khung nội dung (tự đánh số ví dụ/luyện tập):
+`#dinh-nghia[...]`, `#dinh-ly[...]`, `#tinh-chat[...]`, `#vi-du[...]`,
+`#loi-giai[...]`, `#chu-y[...]`, `#ghi-nho[...]`, `#nhan-xet[...]`, `#luyen-tap[...]`.
+
+Bố cục: `#chia-cot(trai, phai)` hoặc `#chia-cot(a, b, c, ti-le: (2fr, 1fr, 1fr))`.
+Các bước giải: `#buoc([Bước 1...], [Bước 2...])`.
+
+## 2. Vẽ hình tự do (ve.typ)
+
+Mọi hình vẽ nằm trong `#hinh(...)` với hệ toạ độ toán học (y hướng lên).
+Để `h: auto` thì hai trục cùng tỉ lệ (nên giữ khi vẽ hình hình học).
+
+```typst
+#hinh(w: 7cm, xmin: -1, xmax: 6, ymin: -1, ymax: 5, ctx => {
+ doan(ctx, (0, 0), (5, 0)) // đoạn thẳng
+ doan(ctx, (1, 1), (4, 3), dut: true, mau: red) // nét đứt
+ duong-gap-khuc(ctx, ((0,0), (1,2), (3,1), (5,4)), mau: blue, dut: true) // gấp khúc A-B-C-D, đứt thật từng đoạn
+ diem(ctx, (2, 3), ten: $M$, huong: "tren") // điểm + nhãn
+ mui-ten(ctx, (0, 0), (3, 2)) // mũi tên
+ mui-ten-2-dau(ctx, (0, -0.6), (5, -0.6), // mũi tên HAI ĐẦU: ghi số đo
+ ten: [5 cm], vach: true) // chữ nằm giữa thân
+ vecto(ctx, (0, 0), (2, 3), ten: $arrow(u)$) // vectơ có tên
+ vecto(ctx, (0, 0), (2, 3), dut: true) // vectơ nét đứt (cạnh khuất), đầu mũi tên vẫn liền
+ duong-tron(ctx, (3, 2), 1.5) // đường tròn
+ cung(ctx, (0, 0), 2, tu: 0deg, den: 90deg) // cung tròn
+ goc(ctx, (0,0), (5,0), (3,4), ten: $alpha$) // đánh dấu góc (cung + nhãn)
+ goc(ctx, (0,0), (5,0), (3,4), so-do: true, // tự ghi số đo góc (vd 60°)
+ to: rgb(255, 170, 0, 70)) // + tô màu hình quạt
+ goc(ctx, (0,0), (5,0), (3,4), vach: 2) // 2 vạch cắt ngang cung
+ goc-vuong(ctx, (0,0), (5,0), (0,4)) // ký hiệu vuông góc
+ ve-goc(ctx, (5,0), (0,0), (3,4), ten: $alpha$) // = goc, nhưng ĐỈNH ở GIỮA (lối TikZ)
+ ve-goc-vuong(ctx, (5,0), (0,0), (0,4)) // = goc-vuong, ĐỈNH ở GIỮA (lối TikZ)
+ danh-dau(ctx, (0,0), (5,0), so: 2) // 2 vạch "bằng nhau"
+ danh-dau(ctx, (0,0), (5,0), so: 2, nghieng: 25deg) // vạch CHÉO "//"
+ danh-dau(ctx, (0,0), (5,0), cheo: true) // dấu ✕ (chéo nhau)
+ ve-ham(ctx, x => x*x/4, mau: blue) // đồ thị hàm bất kỳ
+ to-vung(ctx, x => x*x/4, 0, 3) // tô miền dưới đồ thị
+ nhan(ctx, (3, 4), [chú thích], huong: "phai")
+})
+```
+
+Hướng nhãn: `"tren" | "duoi" | "trai" | "phai" | "tren-trai" | "tren-phai" | "duoi-trai" | "duoi-phai" | "giua"`.
+
+**Mũi tên hai đầu — ghi số đo (`mui-ten-2-dau`)**
+
+Kiểu đường ghi kích thước trong bản vẽ: hai đầu đều có mũi tên, số đo đặt ở
+GIỮA thân (thân tự cắt chừa chỗ cho chữ, có nền lót nên nét không cắt qua chữ).
+
+```typst
+mui-ten-2-dau(A, B, ten: [5 cm]) // chữ giữa thân
+mui-ten-2-dau(A, B, ten: $2a$, vach: true) // + vạch chặn hai đầu
+mui-ten-2-dau(A, B, ten: $sqrt(29)$, trong: false) // chữ đặt NGOÀI, trên đoạn
+mui-ten-2-dau(A, B, ten: [dài], trong: false, ten-quay: true) // chữ nằm dọc
+mui-ten-2-dau(A, B, ten: $h$, le: 3pt) // lùi hai đầu, hở khỏi vật đo
+```
+
+Đoạn ngắn không đủ chỗ chứa chữ thì hàm TỰ đưa chữ ra ngoài thân — không bao
+giờ đè lên mũi tên. `nen: none` để bỏ nền lót, `dem:` nới chỗ hở quanh chữ.
+
+**Nhãn có phân số / căn không còn bị nét vẽ cắt ngang**
+
+Typst tính HỤT khung của công thức trong dòng: `measure($1/2$)` chỉ cho chiều
+cao MỘT DÒNG CHỮ trong khi phân số vẽ ra cao gấp đôi và tràn cả trên lẫn dưới.
+`nhan` (và mọi hàm gọi qua nó: nhãn đoạn, nhãn góc, nhãn sơ đồ cây, nhãn biểu
+đồ…) nay đo thêm theo BIÊN NÉT CHỮ rồi bù đúng phần tràn đó. Chữ thường không
+tràn nên bù 0 — bố cục các hình cũ giữ nguyên.
+
+> **Vị trí ĐỈNH góc — hai lối viết.** `goc`/`goc-vuong` đặt đỉnh làm đối số
+> **ĐẦU**: `goc-vuong(O, A, B)` là góc vuông **tại O**. TikZ thì viết đỉnh ở
+> **GIỮA** (`pic angle = A--O--B`), nên có thêm `ve-goc(A, O, B)` và
+> `ve-goc-vuong(A, O, B)` cho ai quen lối đó — cùng vẽ góc **tại O**, cùng bộ
+> tuỳ chọn (`r`, `ten`, `so-do`, `so-cung`, `vach`, `to`, `mau`, `day`,
+> `cach-nhan`), dùng lẫn nhau trong một hình cũng được. Viết nhầm thứ tự sẽ
+> KHÔNG báo lỗi, chỉ vẽ ký hiệu sai đỉnh — nên chọn một lối rồi giữ nguyên.
+
+### Đường xoắn ốc & góc lượng giác (07/2026)
+
+`xoan-oc` vẽ xoắn ốc Archimedes (bán kính tăng đều theo góc), quét được **nhiều
+vòng**; `goc-luong-giac` là lối gọi tắt cho hình góc lượng giác $(OA, OM)$ của
+SGK 11 — tự tính chiều quay và số vòng.
+
+```typst
+#hinh(xmin: -3, xmax: 3, ymin: -3, ymax: 3, w: 6cm, ctx => {
+ let O = (0, 0)
+ let A = (2.2, 0)
+ let M = toa-cuc(O, 2.2, -70)
+ cac-doan((O, A), (O, M))
+ cac-diem((O, $O$, "above-left"), (A, $A$, "above"), (M, $M$, "right"))
+ goc-luong-giac(O, A, M, chieu: "am", vong: 1) // (OA, OM) = -430°
+})
+```
+
+`goc-luong-giac(O, A, M, ...)`
+
+| Tham số | Ý nghĩa |
+| ----------------------------------- | ------------------------------------------------------------------------------------------------- |
+| `chieu` | `"duong"` (ngược kim đồng hồ, mặc định) · `"am"` (cùng kim đồng hồ) |
+| `vong` | số vòng quay THÊM trước khi dừng ở tia OM (0, 1, 2, …) |
+| `so-do` | `true` = tự ghi số đo (vd −430°) cạnh mũi tên; `ten:` được ưu tiên |
+| `r`, `buoc`, `r-cuoi` | bán kính đầu, khoảng cách 2 vòng, bán kính cuối (`auto` = tính theo chiều dài tia) |
+| `mau`, `day`, `dut`, `kich` | màu (mặc định`blue`), độ dày, nét đứt, cỡ đầu mũi tên |
+| `huong`, `cach`, `mau-ten` | vị trí nhãn số đo quanh điểm cuối |
+
+`xoan-oc(O, tu: 0deg, den: 1080deg, r: 0.1, buoc: 0.85, mau: blue)` — dùng khi
+cần xoắn ốc thuần: `den > tu` quay ngược kim đồng hồ, `den < tu` quay cùng kim
+đồng hồ, `r-cuoi:` đặt bán kính cuối (nhỏ hơn `r` thì xoắn vào trong),
+`mui-ten:` nhận `true`/`"cuoi"`/`"dau"`/`"ca-hai"`/`false`, `ten:` ghi nhãn ở
+điểm cuối.
+
+### `cac-doan` — vẽ nhiều nét trong MỘT lệnh (kiểu `\draw` của TikZ) (07/2026)
+
+```typst
+cac-doan(A, B) // một đoạn
+cac-doan(A, B, C, D) // các điểm liền nhau -> gấp khúc A-B-C-D
+cac-doan((A, B), (C, D)) // mỗi MẢNG điểm là một nét rời
+cac-doan(
+ duong(B, C, D, S, dong: true), // nét khép kín
+ duong(A, B, dut: true), duong(A, D, dut: true),
+ duong(B, D, mau: red, dut: true, ten: $d$, tai: 0.35),
+ day: 1.1pt, // kiểu CHUNG cho mọi nét
+)
+```
+
+`duong(...)` khai báo một nét có kiểu riêng (đè lên kiểu chung). Tuỳ chọn dùng
+được ở cả `duong(...)` lẫn `cac-doan(...)`: `mau`, `day`, `dut`, `dong` (khép
+kín), `to` (tô màu — tự khép kín), `mui-ten` (đầu mũi tên ở điểm cuối), và bộ
+nhãn `ten`/`tai`/`huong`/`cach`/`ten-quay`/`mau-ten` (nhãn đặt tại tỉ lệ `tai`
+tính theo TỔNG chiều dài nét).
+
+### `duong-luon` — đường cong uốn lượn (kiểu `.. controls ..` của TikZ) (07/2026)
+
+```typst
+duong-luon(A, B, C, D) // trơn TỰ ĐỘNG qua các điểm neo
+duong-luon(A, dieu-khien(c1, c2), B) // Bézier bậc ba: 2 điểm điều khiển
+duong-luon(
+ (0,0), dieu-khien((1,2),(2,2)),
+ (3,0), dieu-khien((4,-2),(5,-2)), (6,0), // nhiều đoạn nối tiếp (sóng)
+ mau: teal, day: 1.4pt, mui-ten: true,
+)
+duong-luon(A, B, C, dong: true, to: blue.lighten(85%)) // khép kín + tô
+```
+
+Liệt kê các **điểm neo**; xen `dieu-khien(c1, c2)` giữa hai neo để đặt điểm điều
+khiển như `.. controls (c1) and (c2) ..` của TikZ. Đoạn KHÔNG có `dieu-khien` thì
+tự làm mềm bằng Catmull-Rom (đường trơn đi qua đúng các neo — khỏi tính tay).
+`dieu-khien(c1)` = một điểm điều khiển (c₂ = c₁); để `auto` một thành phần thì
+thành phần đó tự tính. Tuỳ chọn: `mau`, `day`, `dut`, `dong` (khép kín), `to`
+(tô), `mui-ten`, `kich`, bộ nhãn `ten`/`tai`/`huong`/`cach`/`ten-quay`/`mau-ten`
+(đặt theo tỉ lệ độ dài, như `cac-doan`), `n` (số mẫu mỗi đoạn — tăng cho mượt).
+`diem-luon(...)` TRẢ VỀ mảng điểm mẫu (không vẽ) để tái dùng — ví dụ đưa vào
+`nhan-cong` cho chữ bám đúng đường.
+
+### `nhan-cong` — nhãn chữ bám theo một đường cong (07/2026)
+
+```typst
+// chữ chạy dọc nửa trên đường tròn lượng giác
+nhan-cong(diem-cung((0,0), 2.2, 2.2, 160deg, 20deg),
+ "DUONG TRON LUONG GIAC", co: 13pt, mau: blue, can: "giua", tu: 0.5)
+// chữ bám sóng Bézier (lấy đúng mẫu của đường đã vẽ)
+let s = diem-luon((0,0), dieu-khien((1,2),(2,2)), (3,0), n: 24)
+nhan-cong(s, "song song", co: 12pt, phia: "duoi")
+```
+
+Đặt từng ký tự dọc theo `duong` (mảng điểm toạ độ toán — lấy từ `diem-luon`,
+`diem-cung`, `lay-mau`, hay bất kỳ dãy điểm nào), tự xoay tiếp tuyến và canh theo
+độ dài cung. `chu` nên là chuỗi `"..."`. Tuỳ chọn: `tu` (vị trí bắt đầu theo tỉ
+lệ 0..1), `can` (`"trai"`/`"giua"`/`"phai"`), `khoang` (giãn ký tự), `co` (cỡ
+chữ), `mau`, `phia` (`"tren"`/`"duoi"`/`"giua"` so với đường), `cach` (hở chữ↔đường),
+`dao: true` (đảo chiều khi đường vẽ từ phải sang trái, để chữ đọc xuôi).
+
+### `cac-diem` — chấm và đặt tên nhiều điểm trong MỘT lệnh (07/2026)
+
+```typst
+cac-diem(
+ (A, $A$, "below-left"), (B, $B$, "below-right"), (C, $C$, "above"),
+ mau: blue, bk: 2.2pt,
+)
+```
+
+Mỗi đối số là một điểm, viết theo một trong bốn dạng:
+
+- `A` — chỉ chấm, không nhãn;
+- `(A, $A$)` — chấm + nhãn, hướng lấy theo `huong` chung;
+- `(A, $A$, "left")` — thêm hướng riêng;
+- `(A, $A$, "left", red)` — thêm màu riêng (áp cho cả chấm lẫn chữ).
+
+Tuỳ chọn chung: `mau`, `bk` (bán kính chấm), `huong`, `cach` (nhãn cách chấm),
+`mau-ten` (`auto` = theo màu chấm). Lệnh `diem` cũng nhận thêm `cach` và
+`mau-ten` như trên.
+
+### Tiếp tuyến của đồ thị tại một điểm (07/2026)
+
+```typst
+tiep-tuyen(f, 1.5) // tiếp tuyến tại x = 1,5
+tiep-tuyen(f, 1.5, ten: auto, ten-diem: $M$, giong: true) // + phương trình
+tiep-tuyen(f, (-1, 2), mau: green, dut: true) // nhiều tiếp điểm một lệnh
+tiep-tuyen(f, 0, dai: 1.4, ten: $Delta$) // đoạn ngắn quanh tiếp điểm
+let k = dao-ham(f, 1.5) // chỉ lấy hệ số góc
+```
+
+Hệ số góc tính bằng **đạo hàm số** nên dùng được cho mọi hàm viết bằng closure
+(kể cả hàm hợp, hàm căn, lượng giác). Tuỳ chọn: `dai` (`auto` = kéo hết khung,
+tự cắt gọn theo cửa sổ; hoặc nửa độ dài theo trục Ox), `cham`/`mau-cham` (chấm
+tiếp điểm), `ten-diem`/`huong-diem`, `ten` (`auto` = tự ghi `y = kx + m` rút
+gọn), `tai`/`huong-ten`/`cach`/`ten-quay` (vị trí & kiểu nhãn), `giong` (kẻ
+đường gióng đứt từ tiếp điểm về hai trục), `mau`, `day`, `dut`.
+
+### Nhãn chú thích ngay trên đoạn (`doan`) (07/2026)
+
+```typst
+doan(A, B, ten: [6 cm]) // giữa đoạn, tự đặt vuông góc
+doan(A, B, ten: $h$, tai: 0.7, huong: "right", cach: 4pt)
+doan(B, C, ten: [cạnh huyền], ten-quay: true) // chữ nằm dọc theo đoạn
+```
+
+- `tai`: vị trí nhãn theo tỉ lệ từ A đến B (0 = tại A, 1 = tại B, mặc định 0.5).
+- `huong`: `auto` (mặc định) = vuông góc, phía trên đoạn — đoạn thẳng đứng thì
+ ra bên trái; hoặc tên hướng như trên, hoặc vectơ `(dx, dy)` tự chọn.
+- `ten-quay: true`: chữ xoay theo đoạn và luôn đọc xuôi (không lộn ngược).
+- `mau-ten`: mặc định lấy theo màu đoạn.
+
+File thử: `thu-cac-doan.typ`.
+
+Hàm tính toán: `trung-diem`, `khoang-cach`, `chia(A, B, t)` (t = tỉ lệ AM/AB,
+ngoài [0, 1] là điểm trên đường kéo dài), `hinh-chieu(P, A, B)`,
+`tam-ngoai-tiep`, `tam-noi-tiep`, `truc-tam(A, B, C)` (giao ba đường cao),
+`tam-bang-tiep(A, B, C)` → `(J, r)` (tâm + bán kính đường tròn bàng tiếp
+**trong góc A**; muốn góc B thì gọi `tam-bang-tiep(B, C, A)`),
+`tiep-diem`, `giao-hai-duong-tron`,
+`giao-duong-thang(A, B, C, D)` (giao 2 đường thẳng AB, CD),
+`giao-duong-thang-duong-tron((A, B), (O, r))` (giao đường thẳng AB với đường
+tròn (O; r) → MẢNG điểm: rỗng nếu không cắt, 1 điểm nếu tiếp xúc, 2 điểm nếu
+cắt), `dung-diem(A, B, goc, r)` (dựng điểm M: tia AM là tia AB quay quanh A một
+góc lượng giác `goc` (độ, dương ngược kim đồng hồ) và AM = `r`).
+
+Giao với đường cong (07/2026): `giao-ham(f, g, tu, den)` trả về MẢNG giao điểm
+của 2 đồ thị y = f(x), y = g(x) trên [tu, den]; đường thẳng qua 2 điểm đổi thành
+hàm bằng `ham-qua-2-diem(A, B)`; đường đứng x = k thì giao là `(k, f(k))`.
+Ví dụ đầy đủ: `vi-du-ve-tu-do.typ`.
+
+### KHÔNG cần gõ `ctx` nữa (07/2026)
+
+Trong thân `#hinh(...)` và trong `them: ctx => ...` của **mọi** hình/đồ thị
+dựng sẵn, các lệnh vẽ tự lấy khung hình đang vẽ — **bỏ hẳn `ctx`**:
+
+```typst
+them: ctx => {
+ doan(M, N, mau: blue, dut: true)
+ ve-ham(g, mau: red)
+ diem(A, mau: red, ten: $A$)
+}
+```
+
+- Lối cũ `doan(ctx, M, N)` **vẫn chạy y nguyên** (đối số đầu là `ctx` thì gọi
+ thẳng) — bài soạn cũ không phải sửa một chữ nào.
+- **Ngoại lệ vẫn phải truyền `ctx`**: các hàm TRẢ VỀ GIÁ TRỊ chứ không vẽ —
+ `toa`, `toa-pt`, `toa-nguoc`, `goc-truc`, `ctx-quay`, `ctx-tinh-tien`.
+ Ví dụ nhãn nghiêng: `nhan(P, $d$, quay: goc-truc(ctx, A, B))`.
+- Gọi lệnh vẽ ở NGOÀI khung hình sẽ báo lỗi rõ ràng: *"Hàm vẽ cần khung
+ hình: đặt lệnh trong thân #hinh(...) hoặc trong them: ctx => ..."*.
+- Vẫn giữ `ve-voi(ctx)` (bộ hàm gắn sẵn ctx, lấy bằng
+ `let (doan, diem) = ve-voi(ctx)`) cho ai muốn đặt tên riêng, hoặc khi vẽ
+ trên khung đã quay: `ve-voi(ctx-quay(ctx, 30deg))`.
+
+Danh sách 49 lệnh được gọi ngầm: nguyên thuỷ của `ve.typ`
+(`doan`, `diem`, `nhan`, `mui-ten`, `vecto`, `duong-cong`, `duong-tron`,
+`elip`, `cung`, `goc`, `goc-vuong`, `ve-goc`, `ve-goc-vuong`, `danh-dau`, `ve-ham`, `to-vung`,
+`gach-mien`, `gach-vung`, `ve-mien`, `mui-ten`, `dau-mui-ten`…),
+hệ trục của `do-thi.typ` (`truc`, `luoi`, `vach-chia`, `he-truc`, `giong`,
+`nhan-pi`), toàn bộ `hinh-phang.typ` (`tam-giac`, `duong-cao`, `tu-giac`…)
+và nhóm Oxyz (`diem-oxyz`, `doan-oxyz`, `vecto-oxyz`, `giong-oxyz`).
+
+Các hàm KHÔNG cần `ctx` từ trước (`trung-diem`, `chia`, `hinh-chieu`,
+`giao-ham`, `ham-qua-2-diem`, `so-toan`, `mien-tron`, `giao`/`hop`/`bu`…)
+vẫn gọi thẳng như cũ.
+
+> `giao-ham` chỉ **TÍNH**, không vẽ ⇒ **không truyền `ctx`**; kết quả đem cho
+> `diem`/`nhan` mới ra hình. Cận nhận cả 2 lối viết: `giao-ham(f, g, -3, 2)`
+> hoặc `giao-ham(f, g, tu: -3, den: 2)`. Lỡ gõ `giao-ham(ctx, f, g, ...)` thì
+> `ctx` được bỏ qua chứ không báo lỗi. Quét qua tiệm cận đứng cũng an toàn:
+> chỗ hàm "nhảy" bị loại khỏi kết quả.
+>
+> ```typst
+> them: ctx => {
+> let f = x => (x + 2)/(x + 1)
+> let g = x => x + 1
+> ve-ham(g, mau: red)
+> for P in giao-ham(f, g, -3, 2) { diem(P, mau: red, bk: 2.2pt) }
+> }
+> ```
+
+## 3. Hình phẳng (hinh-phang.typ)
+
+```typst
+#hinh(w: 7cm, xmin: -1, xmax: 6, ymin: -1, ymax: 5, ctx => {
+ let (A, B, C) = ((0.5, 0), (5.5, 0), (3.5, 4))
+ tam-giac(ctx, A, B, C) // nhãn đỉnh tự đặt
+ duong-cao(ctx, C, A, B, ten-chan: $H$)
+ trung-tuyen(ctx, A, B, C, ten-chan: $M$)
+ phan-giac(ctx, B, C, A, ten-chan: $D$)
+ phan-giac(ctx, B, C, A, vach: 1) // 1 vạch: hai góc BẰNG NHAU
+ phan-giac(ctx, B, C, A, so-cung: 2) // hoặc 2 cung đồng tâm
+ trung-truc(ctx, A, B)
+ duong-tron-ngoai-tiep(ctx, A, B, C) // tâm O tự tính
+ duong-tron-noi-tiep(ctx, A, B, C)
+})
+```
+
+**Đường tròn ngoại tiếp ĐA GIÁC** — `duong-tron-ngoai-tiep` nhận cả một MẢNG
+đỉnh, không chỉ tam giác. Từ 4 đỉnh trở lên, tâm và bán kính khớp theo bình
+phương bé nhất (đa giác nội tiếp được vẫn cho ĐÚNG đường tròn của nó).
+
+```typst
+#let P = range(5).map(i => toa-cuc((0, 0), 1, 90 + i * 72)) // ngũ giác đều
+#duong-tron-ngoai-tiep(P, canh: true, ban-kinh: true, ten-r: $R$)
+#let (O, R) = tron-qua-diem(P) // TRẢ VỀ (tâm, bán kính), không vẽ
+```
+
+Nhãn tâm tự chọn phía THOÁNG nhất nên không rơi lên cạnh đa giác (`huong-tam:`
+để chỉ định tay). `ban-kinh: true` cũng tự chọn đỉnh mà bán kính không nằm đè
+lên cạnh — vd tam giác vuông có tâm ở giữa cạnh huyền thì bán kính vẽ tới đỉnh
+góc vuông chứ không trùng cạnh huyền.
+
+**Khung vừa khít hình (`khung-vua`)** — đường tròn ngoại tiếp thường chìa ra
+ngoài đa giác nên rất dễ tràn khỏi `#hinh`. `khung-vua` trả về cửa sổ toạ độ
+vừa khít, rải thẳng vào `#hinh`:
+
+```typst
+#let (O, R) = tron-qua-diem((A, B, C))
+#hinh(w: 5cm, ..khung-vua((A, B, C), (O, R)), ctx => {
+ tam-giac(ctx, A, B, C)
+ duong-tron-ngoai-tiep(ctx, A, B, C, ban-kinh: true, ten-r: $R$)
+})
+```
+
+Mỗi đối số là một ĐIỂM `(x, y)`, một ĐƯỜNG TRÒN `(tâm, bán kính)`, hoặc một
+MẢNG gồm các thứ đó; `le:` chừa lề theo tỉ lệ cạnh lớn (mặc định `0.12`).
+
+Sẵn có thêm: `tu-giac`, `hinh-binh-hanh` (biết 3 đỉnh), `hinh-chu-nhat`,
+`hinh-thang`, `tiep-tuyen-tu-diem(ctx, O, r, M)` (hai tiếp tuyến + góc vuông),
+`tiep-tuyen-tai-diem(O, r, M, dai: auto, dut: false)` (tiếp tuyến TẠI điểm M
+trên đường tròn — đoạn qua M vuông góc OM; `dai` = nửa độ dài đoạn),
+`duong-tron-luong-giac(so-do: 55deg)` (hình hoàn chỉnh, tự tạo khung).
+
+Nhãn theo GÓC LƯỢNG GIÁC — `nhan-goc(..muc)` đặt nhãn nhiều điểm, phía đặt nhãn
+xác định bằng góc (thay tên hướng): mỗi mục `(P, nội-dung, goc)` /
+`(P, nội-dung, goc, ban-kinh)` / `(P, nội-dung, goc, ban-kinh, mau)`, `goc` số
+trần = độ (dương ngược kim đồng hồ), `ban-kinh` là độ dài trang (điểm→nhãn).
+Ví dụ `nhan-goc((A, $A$, 0), (B, $B$, 120, 8pt), (C, $C$, 240, 8pt, red))`.
+
+`duong-tron-ngoai-tiep` và `duong-tron-noi-tiep` nhận thêm `dut: true` để vẽ
+đường tròn bằng NÉT ĐỨT. File thử các hàm này: `thu-hinh-tron-moi.typ`.
+
+**Trực tâm & đường tròn bàng tiếp (07/2026)** — trọn bộ công cụ tam giác:
+
+```typst
+ve-truc-tam(A, B, C) // 3 đường cao + ký hiệu vuông góc + H
+ve-truc-tam(A, B, C, canh: false, // không vẽ lại cạnh tam giác
+ ten: $H$, ten-chan: ($H_A$, $H_B$, $H_C$))
+duong-tron-bang-tiep(A, B, C) // bàng tiếp TRONG GÓC A
+duong-tron-bang-tiep(A, B, C, ban-kinh: true, // + bán kính tới BC
+ ten-tam: $J_A$, ten-tiep: ($X$, $Y$, $Z$))
+duong-tron-bang-tiep(B, C, A) // bàng tiếp trong góc B
+```
+
+`ve-truc-tam` tự kéo dài cạnh bằng nét đứt khi tam giác tù (chân đường cao rơi
+ra ngoài cạnh); `duong-tron-bang-tiep` tự kéo dài AB, AC tới tiếp điểm
+(`keo-dai: false` để tắt).
+
+Tam giác đặc biệt (tự vẽ ký hiệu vuông góc, vạch cạnh bằng nhau):
+
+```typst
+tam-giac-deu(ctx, A, canh) // đều
+tam-giac-vuong(ctx, A, a, b) // vuông tại A, hai cạnh a × b
+tam-giac-can(ctx, A, canh-day, chieu-cao) // cân tại C
+tam-giac-vuong-can(ctx, A, canh) // vuông cân tại A
+```
+
+## 4. Hình không gian (hinh-khong-gian.typ)
+
+Các hình hoàn chỉnh, nét khuất tự vẽ đứt theo quy ước SGK:
+
+```typst
+#hinh-chop-tam-giac() // S.ABC
+#hinh-chop-tu-giac(duong-cao: "tam", duong-cheo: true) // chóp đều S.ABCD
+#hinh-chop-tu-giac-thuong(duong-cheo: true) // chóp tứ giác thường (góc nhìn khác)
+#hinh-chop-day-hinh-thang(duong-cheo: true) // chóp đáy hình thang
+#hinh-chop-tu-giac(duong-cao: "dinh-a") // SA ⊥ đáy
+#hinh-hop(duong-cheo: true) // hộp + đường chéo AC′ (nghieng: độ xiên)
+#hinh-hop-chu-nhat(duong-cheo: true) // hộp chữ nhật dài (nhìn thẳng, nghieng: 0)
+#hinh-lap-phuong()
+#hinh-lang-tru-tam-giac()
+#hinh-non() #hinh-tru() #hinh-cau()
+#truc-oxyz(don-vi: true)
+
+// Hình chóp đặc biệt
+#hinh-chop-tam-giac-deu(trung-tuyen: true) // S.ABC đều, SO ⊥ đáy tại trọng tâm
+#hinh-chop-tu-giac-deu() // S.ABCD đều, 2 đường chéo + SO
+#hinh-chop-tam-dien-vuong() // O.ABC: OA, OB, OC đôi một vuông góc
+#hinh-chop-day-tam-giac-vuong() // ABC vuông tại B, SA ⊥ (ABC)
+#hinh-chop-day-chu-nhat() // đáy chữ nhật, SA ⊥ đáy
+```
+
+**Vẽ thêm lên hình có sẵn** bằng `them` — nhận `ctx` và từ điển đỉnh `d`
+(`d.S`, `d.A`, `d.B`, `d.C`, `d.D`, hình hộp/lăng trụ có `d.A1` = A′…):
+
+```typst
+#hinh-chop-tu-giac(them: (ctx, d) => {
+ let M = trung-diem(d.S, d.C)
+ diem(ctx, M, ten: $M$, huong: "phai", mau: blue)
+ doan(ctx, d.B, M, mau: blue, dut: true)
+ da-giac(ctx, (d.A, M, d.B), to: rgb(30,100,200,40)) // thiết diện mờ
+})
+```
+
+## 5. Đồ thị hàm số (do-thi.typ)
+
+**Vẽ nhanh nhất** — chỉ cần công thức và màu, cửa sổ tự chọn:
+
+```typst
+#ve-do-thi(x => x*x*x - 3*x, mau: red)
+#ve-do-thi(x => calc.sin(2*x) + x/2, mau: purple, ten: $y = sin 2x + x/2$)
+#ve-do-thi(x => calc.exp(x) - 2, mau: blue, xmin: -3, xmax: 2)
+```
+
+(Hàm cần xác định trên đoạn vẽ; hàm có tiệm cận đứng dùng `do-thi-phan-thuc`.)
+
+Đồ thị dựng sẵn (tự chọn cửa sổ đẹp, gióng điểm đặc biệt):
+
+```typst
+#do-thi-bac-nhat(2, -1) // y = 2x − 1
+#do-thi-bac-hai(1, -2, -1) // parabol, gióng đỉnh
+#do-thi-bac-ba(1, -3, 0, 2) // gióng 2 cực trị
+#do-thi-trung-phuong(1, -2, 0)
+#do-thi-phan-thuc(2, -1, 1, -1) // y=(2x−1)/(x−1), 2 tiệm cận đứt đỏ
+#do-thi-mu(2) #do-thi-log(2)
+#do-thi-sin() #do-thi-cos() #do-thi-tan() // nhãn π/2, π, 2π
+#do-thi-can()
+```
+
+Mọi đồ thị dựng sẵn đều nhận `luoi-o: true` (lưới ô vuông mờ) và `vach: true`
+(vạch chia + số trên hai trục; riêng sin/cos/tan chỉ có `luoi-o` vì trục hoành
+đã ghi theo π). Khi bật `vach`, nên đặt `giao-ox: none, giao-oy: none` để nhãn
+giao trục không chồng lên số vạch.
+
+Vẽ tay trong `#hinh`: `he-truc(ctx)` gộp lưới + trục + vạch chia + số một lệnh:
+
+```typst
+#hinh(xmin: -4, xmax: 4, ymin: -3, ymax: 3, ctx => {
+ he-truc(ctx) // tuỳ chọn: luoi-o, vach, so, buoc-x, buoc-y, ten-x...
+ ve-ham(ctx, x => x*x - 2, mau: blue)
+})
+```
+
+Hàm bất kỳ + vẽ chồng qua `them`:
+
+```typst
+#do-thi-ham(x => calc.abs(x*x - 2*x), xmin: -2, xmax: 4, ymin: -1, ymax: 4,
+ ten: $y = |x^2 - 2x|$, luoi-o: true, vach: true,
+ them: ctx => {
+ ve-ham(ctx, x => 1.5, mau: red, dut: true) // đường y = m
+ giong(ctx, (3, 3)) // gióng điểm về 2 trục
+ })
+```
+
+### Nhiều đồ thị trên cùng một hệ trục
+
+Mỗi hàm gói bằng `ham(...)`; `giao-diem: auto` chấm đỏ giao điểm từng cặp:
+
+```typst
+#do-thi-nhieu-ham(
+ ham(x => x*x - 2, mau: blue, ten: $y = x^2 - 2$),
+ ham(x => x, mau: red, ten: $y = x$, dut: true, tai: -2), // tai: hoành độ đặt nhãn
+ xmin: -3, xmax: 3, ymin: -3, ymax: 4,
+ giao-diem: auto,
+)
+```
+
+### Miền nghiệm BPT / hệ BPT bậc nhất hai ẩn
+
+Quy ước SGK: gạch phần **không** là miền nghiệm; biên nét đứt nếu BPT ngặt.
+Mỗi BPT `a·x + b·y (dau) c` khai báo bằng `bpt(a, b, c, dau: "<=")`:
+
+Nhãn `ten:` mặc định **NẰM NGHIÊNG theo chiều đường thẳng** (tự đọc xuôi, không
+lộn ngược); đặt tại điểm `ten-tai:` (0..1 dọc đoạn nhìn thấy). `huong-ten:` chọn
+**phía** đặt nhãn so với đường: `"above"` (trên) hoặc `"below"` (dưới). Muốn chữ
+nằm ngang như cũ thì `nghieng-ten: false` (khi đó `huong-ten` nhận mọi hướng
+`"above"/"below"/"left"/"right"/…`). Khoảng cách tới đường: `cach-ten:`.
+
+Kiểu gạch chéo đặt được RIÊNG cho từng BPT (giống `mau-gach`): `goc-gach:` (góc)
+và `buoc-gach:` (bước) trong `bpt(...)` — `auto` = theo giá trị chung khai ở
+`mien-nghiem`.
+
+```typst
+#mien-nghiem(bpt(4, 5, -8, dau: "<"), giao-truc: auto) // 4x + 5y < −8
+#mien-nghiem( // hệ BPT + tô miền
+ bpt(3, -2, -9, mau: red, ten: $3x - 2y = -9$, ten-tai: 0.7, huong-ten: "above"),
+ bpt(-3, 5, 18, mau: green.darken(25%), ten: $-3x + 5y = 18$, huong-ten: "below",
+ goc-gach: 135deg), // gạch riêng đường này
+ to-mien: rgb(40, 90, 200, 35),
+ xmin: -7.5, xmax: 2.5, ymin: -1.5, ymax: 5,
+)
+```
+
+### Diện tích hình phẳng giới hạn bởi 2 đồ thị
+
+`g` mặc định là trục hoành; `a, b: auto` = tự lấy giao điểm ngoài cùng
+(trong phạm vi `tu..den`); miền tô tự tách tại giao điểm ở giữa:
+
+```typst
+#dien-tich-2-ham(x => x*x, g: x => x + 2) // tự tìm giao điểm
+#dien-tich-2-ham(x => calc.sin(x), a: 0.5, b: 2.6) // trên đoạn [a, b]
+#dien-tich-2-ham(x => x*x - 1, g: x => -x - 1, a: -2, b: 1.5,
+ ten-f: $y = x^2 - 1$, ten-g: $y = -x - 1$, mau-to: rgb(200, 60, 60, 60))
+```
+
+Cần vẽ tay trong `them` của đồ thị khác: `to-vung-2-ham(ctx, f, g, a, b)`
+(và `to-vung(ctx, f, a, b)` với trục hoành); gạch chéo đa giác lồi:
+`gach-mien(ctx, cac-dinh)`.
+
+### Khối tròn xoay (07/2026)
+
+`#khoi-tron-xoay(f, a, b)` vẽ **hai hình cạnh nhau**: trái là miền phẳng đã tô
+màu (giới hạn bởi `y = f(x)`, trục hoành, `x = a`, `x = b`), phải là khối tròn
+xoay sinh ra khi quay miền đó quanh `Ox` — nét khuất tự vẽ đứt.
+
+```typst
+#khoi-tron-xoay(x => calc.sqrt(x), 0, 4, ten-ham: $y = sqrt(x)$, the-tich: true)
+#khoi-tron-xoay(x => 0.35*x*x + 0.5, 0, 3, mat-cat: 2) // thiết diện tại x = 2
+#khoi-tron-xoay(x => 1.6, 0, 3, g: x => 0.7) // khối rỗng (vành khăn)
+#khoi-tron-xoay(y => 0.6 + 0.5*y, 0, 3, truc: "Oy") // quay quanh Oy
+#khoi-tron-xoay(f, a, b, hien: "khoi") // chỉ lấy khối 3D
+#khoi-tron-xoay(f, a, b, hien: "mien") // chỉ lấy miền phẳng
+```
+
+Tham số: `truc` (`"Ox"` mặc định | `"Oy"` — khi đó `f` là bán kính theo `y`),
+`hien` (`"ca-hai"` | `"mien"` | `"khoi"`), `g` (bán kính trong → vành khăn),
+`mat-cat: c` (vẽ đĩa/vành thiết diện tại `c` + bán kính, kèm `ten-ban-kinh`,
+`ten-mat-cat`), `the-tich: true` (ghi công thức `V = π∫f²dx` dưới hình),
+`w` (bề rộng hình khối), `k` (độ "dẹt" của elip, mặc định 0.26), `ten-ham`,
+`ten-ham-trong`, `ten-a`, `ten-b`, `ten-goc`, `nhan-giua`, `mau`, `mau-to`,
+`mau-mien`, `them` / `them-mien` (vẽ chồng vào hình khối / hình miền).
+
+Vẽ riêng vào một khung `#hinh` có sẵn: `ve-khoi-xoay(f, a, b, ...)` và
+`ve-mien-xoay(f, a, b, ...)` (thêm `ngang: false` cho trục quay thẳng đứng).
+
+Gạch chéo **miền bất kì** (biên cong, hợp/giao/hiệu tuỳ ý): khai báo mỗi hình
+đúng MỘT lần bằng `mien-tron(O, r)` / `mien-elip(O, a, b, quay:)`, vẽ biên bằng
+`ve-mien(ctx, m)`, gạch bằng `gach-vung` + phép tập hợp `giao`/`hop`/`bu` —
+đổi tâm/bán kính một chỗ là vẽ và gạch tự khớp. Ví dụ Ven $(A sect B) without C$:
+
+```typst
+#hinh(w: 4.5cm, xmin: -2, xmax: 2, ymin: -2, ymax: 2, ctx => {
+ let A = mien-tron((-0.5, 0.3), 0.9)
+ let B = mien-tron((0.5, 0.3), 0.9)
+ let C = mien-tron((0, -0.5), 0.5) // bán kính khác nhau thoải mái
+ ve-mien(ctx, A)
+ ve-mien(ctx, B)
+ ve-mien(ctx, C)
+ gach-vung(ctx, giao(A, B, bu(C)), mau: black, day: 0.4pt, buoc: 4.5pt)
+})
+```
+
+`giao`/`hop`/`bu` lồng nhau tuỳ ý: `hop(A, B)`, `giao(A, hop(B, C))`,
+`bu(hop(A, B))`... Miền không phải tròn/elip thì `gach-vung` vẫn nhận hàm
+`P => true/false` như cũ; helper điểm: `trong(P, m)` (nhận miền hoặc hàm),
+`trong-tron(P, O, r)`, `trong-elip(P, O, a, b, quay:)`.
+
+Tuỳ chọn: `goc:` (hướng vạch, mặc định 45°), `buoc:` (khoảng cách vạch),
+`n:` (số mẫu mỗi vạch — biên càng mịn).
+
+### Phép quay & tịnh tiến (ve.typ)
+
+Elip xoay: `elip(ctx, O, a, b, quay: 30deg)` (dương = ngược chiều kim đồng hồ,
+xoay quanh tâm O); kiểm tra điểm cùng góc: `trong-elip(P, O, a, b, quay: 30deg)`.
+`cung` và `cung-elip` cũng nhận `quay:` (xoay cung quanh tâm).
+
+Quay/tịnh tiến **cả cụm hình** — mọi hàm vẽ theo điểm (doan, da-giac, tam-giac,
+duong-tron, diem, nhan, vecto...) đều tự biến đổi khi vẽ bằng ctx đã bọc:
+
+```typst
+#hinh(w: 7cm, xmin: -1, xmax: 8, ymin: -1, ymax: 4, ctx => {
+ let ve-hinh = c => {
+ tam-giac(c, (0, 0), (2, 0), (0.6, 1.6))
+ duong-tron(c, (1, 0.55), 0.4, mau: blue)
+ }
+ ve-hinh(ctx) // hình gốc
+ ve-hinh(ctx-quay(ctx, 35deg, tam: (0, 0))) // ảnh quay quanh O
+ ve-hinh(ctx-tinh-tien(ctx-quay(ctx, 35deg, tam: (0, 0)), (4.5, 0.5)))
+}) // quay trước, tịnh tiến sau
+```
+
+Biến đổi điểm lẻ: `quay-diem(P, tam, goc)`, `tinh-tien-diem(P, v)`.
+
+Toạ độ CỰC kiểu TikZ — `toa-cuc(tam, bk, goc)` trả về điểm cách `tam` một
+khoảng `bk`, quay góc `goc` (số trần = ĐỘ, nhận cả `30deg`; dương = ngược
+kim đồng hồ). TikZ `(30:2)` ⇔ `toa-cuc((0,0), 2, 30)`. Ví dụ lục giác đều:
+
+```typst
+#let dinh = range(6).map(k => toa-cuc((0, 0), 2.6, 60 * k))
+#duong-gap-khuc(ctx, dinh, dong: true)
+```
+
+Lưu ý: `gach-vung` mô tả miền theo toạ độ GỐC của khung (không qua ctx-quay);
+riêng elip xoay đã có `trong-elip(..., quay:)` tương ứng.
+
+### Sơ đồ cây xác suất (so-do-cay.typ)
+
+Cây sâu tuỳ ý; nút lá có ô kết quả bên phải; màu ô tự đổi theo cấp:
+
+```typst
+#so-do-cay(
+ goc: $1$,
+ nhanh: (
+ nut($A$, xs: $1/6$, con: (
+ nut($B$, xs: $1/2$, kq: $A B: 1/12$),
+ nut($overline(B)$, xs: $1/2$, kq: $A overline(B): 1/12$),
+ )),
+ nut($overline(A)$, xs: $5/6$, con: (
+ nut($B$, xs: $1/3$, kq: $overline(A) B: 5/18$),
+ nut($overline(B)$, xs: $2/3$, kq: $overline(A) overline(B): 5/9$),
+ )),
+ ),
+)
+```
+
+### Hệ trục Oxyz theo đơn vị thật (hinh-khong-gian.typ)
+
+`oxyz` vẽ 3 trục + vectơ đơn vị $arrow(i), arrow(j), arrow(k)$; `them`
+nhận `(ctx, t3)` với `t3` đổi toạ độ $(x, y, z)$ thành điểm 2D — dùng
+được với mọi hàm vẽ phẳng:
+
+```typst
+#oxyz(x: 5, y: 8, z: 8, them: (ctx, t3) => {
+ giong-oxyz(ctx, t3, (5, 8, 8)) // hộp gióng nét đứt
+ vecto-oxyz(ctx, t3, (0, 0, 0), (5, 8, 8), mau: red)
+ diem-oxyz(ctx, t3, (5, 8, 8), ten: $B$, huong: "above-right")
+})
+```
+
+Kèm `doan-oxyz(ctx, t3, A, B)`; điểm nằm trong (Oxy) chỉ vẽ hình gióng phẳng.
+Xem `thu-ve-moi.typ` để có ví dụ đầy đủ của cả 6 định nghĩa.
+
+Mới (07/2026): `vach: true` vạch chia đơn vị trên 3 trục, `so: true` ghi số
+tại các vạch, `buoc:` bước chia, `luoi:` lưới trên mặt phẳng toạ độ —
+`"xy"` | tuple `("xy", "xz", "yz")` | `true` (cả 3), màu chỉnh bằng `mau-luoi`:
+
+```typst
+#oxyz(x: 3, y: 4, z: 3, vach: true, so: true, luoi: "xy")
+```
+
+## 6. Bảng biến thiên & bảng xét dấu (bang.typ)
+
+**BBT tự động từ hệ số** (07/2026) — chỉ nhập hệ số (positional), thuật toán
+tự tìm cực trị/tiệm cận/chiều biến thiên, phủ mọi trường hợp, nhãn số đẹp
+(phân số, căn, `(m ± k√n)/q`):
+
+```typst
+#bbt-bac-hai(1, -3, 1) // y = x² − 3x + 1
+#bbt-bac-ba(1, 0, -3, 1) // y = x³ − 3x + 1: 2 cực trị / Δ'=0 / đơn điệu
+#bbt-trung-phuong(1, -2, 0) // y = x⁴ − 2x²: 3 hoặc 1 cực trị
+#bbt-phan-thuc(2, -1, 1, -1) // y = (2x−1)/(x−1): tự xét dấu ad − bc
+#bbt-huu-ti(1, -1, 1, 1, -1) // y = (x²−x+1)/(x−1): 2 cực trị / đơn điệu
+#bbt-can-bac-hai-ham-bac-hai(1, 0, -4) // y = √(x²−4): tự phân 3 TH theo a, Δ
+```
+
+`bbt-can-bac-hai-ham-bac-hai(a, b, c)` — BBT hàm $y = sqrt(a x^2 + b x + c)$,
+đỉnh $x_0 = -b\/(2a)$, giá trị đỉnh $sqrt((4 a c - b^2)\/(4a))$ (in căn thức
+chính xác). Ba trường hợp: **a > 0, Δ > 0** (2 nghiệm) → TXĐ $(-oo, x_1] union
+[x_2, +oo)$, khoảng giữa gạch chéo (ngoài TXĐ), giá trị 2 nghiệm $= 0$;
+**a > 0, Δ ≤ 0** (nghiệm kép / vô nghiệm) → TXĐ $RR$, cực tiểu tại đỉnh
+(Δ = 0 → min $= 0$, $y'$ không xác định tại đỉnh, ghi ‖); **a < 0, Δ > 0**
+(2 nghiệm) → TXĐ $[x_1, x_2]$, cực đại tại đỉnh (a < 0 mà Δ ≤ 0 → TXĐ rỗng, báo lỗi).
+
+Form cũ (named) vẫn chạy: `bbt-bac-hai(a: 1, xd:, yd:)`, `bbt-bac-ba(a: -1, x1: $-1$, ...)`,
+`bbt-bac-ba-don-dieu(a: 1)`, `bbt-trung-phuong(a: 1, x0:, yc:, y0:)`,
+`bbt-phan-thuc(x0: $1$, y0: $2$, dong-bien: false)`, `xet-dau-tam-thuc(a: 1, x1: $1$, x2: $2$)`.
+
+**Khảo sát & vẽ đồ thị tự động** (07/2026, `khao-sat.typ`) — chỉ nhập hệ số là
+xổ ra TRỌN lời giải (tập xác định, đạo hàm, giới hạn, chiều biến thiên, cực trị
+/ tiệm cận, bảng biến thiên) **kèm đồ thị**, phủ mọi trường hợp của mỗi hàm:
+
+```typst
+#khao-sat-ve-do-thi-ham-bac-hai(1, -2, -3) // y = x² − 2x − 3 (a>0/a<0, Δ mọi dấu)
+#khao-sat-ve-do-thi-ham-bac-ba(1, 0, -3, 1) // y = x³ − 3x + 1 (2 cực trị / Δ'=0 / đơn điệu)
+#khao-sat-ve-do-thi-ham-trung-phuong(1, -2, 0) // y = x⁴ − 2x² (3 hoặc 1 cực trị)
+#khao-sat-ve-do-thi-ham-phan-thuc(2, -1, 1, -1) // y = (2x−1)/(x−1) (đồng/nghịch biến)
+#khao-sat-ve-do-thi-ham-huu-ti(1, 4, 20, 1, 2) // y = (x²+4x+20)/(x+2) (2 cực trị / đơn điệu)
+```
+
+Tham số chung: `tieu-de:` (auto = dòng đậm "Khảo sát… y = …"; `none` = bỏ; hoặc
+nội dung tuỳ ý), `w:` bề rộng đồ thị (mặc định 7.6cm), `giua: true` canh giữa
+BBT + đồ thị. Đặt thẳng trong thân câu hoặc trong `loi-giai:` của `#tl(...)`.
+
+Bảng tuỳ ý — quy ước: `dau` dài `2n−1` xen kẽ *[tại mốc, trên khoảng, …]*;
+tại điểm gián đoạn thêm chỉ số vào `kep` và cho `gia-tri` một **cặp** (trái, phải):
+
+```typst
+// y = x³ − 3x² + 2
+#bbt(
+ x: ($-oo$, $0$, $2$, $+oo$),
+ dau: ("", "+", "0", "-", "0", "+", ""),
+ gia-tri: ($-oo$, $2$, $-2$, $+oo$),
+ huong: ("len", "xuong", "len"),
+)
+
+// y = (2x−1)/(x−1): tiệm cận đứng x = 1 (kẹp ‖)
+#bbt(
+ x: ($-oo$, $1$, $+oo$),
+ dau: ("", "-", "||", "-", ""),
+ gia-tri: ($2$, ($-oo$, $+oo$), $2$),
+ huong: ("xuong", "xuong"),
+ kep: (1,),
+)
+
+// Bảng xét dấu nhiều dòng
+#bang-xet-dau(
+ x: ($-oo$, $1$, $3$, $+oo$),
+ dong: (
+ ($x - 1$, ("", "-", "0", "+", "", "+", "")),
+ ($x - 3$, ("", "-", "", "-", "0", "+", "")),
+ ($f(x)$, ("", "+", "0", "-", "0", "+", "")),
+ ),
+)
+```
+
+Ký hiệu dấu: `"+"`, `"-"`, `"0"`, `"||"` (kẹp), `""` (trống); `huong`: `"len" | "xuong" | "ngang"`.
+
+**Tự bù ô trống hai đầu** (08/2026) — dãy `dau` phải dài `2n−1` nên **hai đầu
+luôn là ô trống `""`**; AI sinh bài rất hay quên đúng hai ô đó. Nay `bbt` và
+`bang-xet-dau` **tự chèn thêm** (chỉ chèn ở ĐẦU/CUỐI, không bao giờ chèn vào
+giữa; thiếu 1 ô thì tự đoán đầu nào thiếu theo quy ước `"+"`/`"-"` nằm trên
+khoảng, `"0"`/`"||"` nằm tại mốc). Thiếu quá 2 ô hoặc thừa ô thì báo lỗi bằng
+tiếng Việt nói rõ cần bao nhiêu phần tử — thay cho `array index out of bounds`.
+Kèm theo: `dau` nhận cả số trần (`0` thay vì `"0"`), còn `x` và `gia-tri` nhận
+chuỗi `"-oo"`/`"+oo"` và số trần (`-5/3`) rồi tự quy về nội dung toán. Nghĩa là
+đoạn dưới đây chạy đúng y như bản viết đủ:
+
+```typst
+#bbt( // thiếu 2 ô trống + "-oo" dạng chuỗi
+ x: ("-oo", -3, -1, "+oo"),
+ dau: ("+", 0, "-", 0, "+"),
+ gia-tri: ("-oo", 5, -5/3, "+oo"),
+ huong: ("len", "xuong", "len"),
+)
+```
+
+File thử: `thu-bbt-bu-dau.typ`.
+
+**Mũi tên nửa ô** (07/2026) — khi hàm đơn điệu qua mốc y′ = 0 (nghiệm kép),
+để 2 mũi tên cùng chiều nằm **trên 1 đường thẳng** đi qua giá trị đặt giữa ô:
+`"len-duoi"` (đáy→giữa) rồi `"len-tren"` (giữa→đỉnh); nghịch biến dùng
+`"xuong-tren"` (đỉnh→giữa) rồi `"xuong-duoi"` (giữa→đáy). Giá trị tại mốc kề
+đầu mút "giữa" tự đặt chính giữa ô — không cần box thủ công:
+
+```typst
+// y = x³ − 3x² + 3x: y' = 0 kép tại x = 1, vẫn đồng biến
+#bbt(
+ x: ($-oo$, $1$, $+oo$),
+ dau: ("", "+", "0", "+", ""),
+ gia-tri: ($-oo$, $1$, $+oo$),
+ huong: ("len-duoi", "len-tren"),
+)
+```
+
+`bbt-bac-ba(a, b, c, d)` (và `khao-sat-ve-do-thi-ham-bac-ba`) trường hợp
+Δ′ = 0 tự dùng kiểu này.
+
+## 7. Câu hỏi định dạng đề thi 2025 (cau-hoi.typ)
+
+Bảy dạng: MC/TF/SA/TL đánh số "Câu N" liên tục chung; **HĐ** (hoạt động)
+đánh "HĐ1, HĐ2...", **Luyện tập** đánh "Luyện tập 1, 2..." (chung bộ đếm
+với khung `#luyen-tap`) và **Vận dụng** đánh "Vận dụng 1, 2..." — mỗi loại
+bộ đếm riêng. Ngoài ra có 3 hình thức hoạt động SGK **KHÔNG đánh số**:
+`#kham-pha` (Khám phá, xanh lục), `#trai-nghiem` (Trải nghiệm, xanh lá),
+`#thao-luan` (Thảo luận, xanh dương) — thân giống `#hd` (dùng được `loi-giai:`,
+`hinh:`, `lo-da:`, `cot-item`…). **Một công tắc** đổi giữa bản giáo viên
+(hiện đáp án, tô xanh) và bản chiếu cho học sinh: `#bat-dap-an()` / `#tat-dap-an()`.
+Ở chế độ **beamer**, công tắc tự BẬT sẵn (kể cả khi dùng `bai-giang` trực tiếp,
+không qua `de-toan`) — đáp án được đánh dấu ở bước `lo-da` (bước cuối) của mỗi câu;
+muốn ẩn thì gọi `#tat-dap-an()`.
+Đánh số lại — MỖI thể loại có bộ đếm & hàm đặt lại RIÊNG (không còn đặt lại
+tất cả cùng lúc): `#dat-lai-cau()` chỉ nhóm **Câu** (tn/ds/tln/tl);
+`#dat-lai-cau-vd()` Ví dụ, `#dat-lai-cau-hd()` Hoạt động, `#dat-lai-cau-lt()`
+Luyện tập, `#dat-lai-cau-vdtt()` Vận dụng thực tế; `#dat-lai-cau-tat-ca()` đặt
+lại cả 5 nhóm cùng lúc (hành vi cũ). Không tham số hoặc `(0)` → về 1;
+`(3)` → đánh tiếp từ 4 (tổng quát `n` → từ n + 1).
+
+```typst
+// MC — 4 phương án, cot: 1 | 2 | 4
+#cau-mc([Câu hỏi...?], ($1$, $2$, $3$, $4$), dap-an: "B", cot: 4,
+ loi-giai: [Hướng dẫn giải...])
+
+// TF — 4 ý đúng/sai
+#cau-tf([Đề bài...], ([ý a], [ý b], [ý c], [ý d]),
+ dap-an: (true, true, false, false), loi-giai: [...])
+
+// SA — trả lời ngắn (khi ẩn đáp án sẽ hiện 4 ô điền như phiếu TLĐ)
+#cau-sa([Tính ...?], dap-an: $12$, loi-giai: [...])
+
+// TL — tự luận; lời giải chỉ hiện khi bật đáp án
+#cau-tl([Đề bài...], diem: 2, loi-giai: [Hướng dẫn...], cho-trong: 3cm)
+
+// HĐ — hoạt động (khởi động/khám phá): thẻ "HĐ1" cam, hình thức như TL;
+// nhiều ý hỏi thì dùng #cot-item trong thân câu
+#cau-hd([Cho hàm số... #cot-item([Tìm GTLN...], [Tìm GTNN...])],
+ loi-giai: [a) ... \ b) ...])
+
+// LT — luyện tập (củng cố lý thuyết): thẻ "Luyện tập 1" vàng đậm,
+// chung bộ đếm với khung #luyen-tap
+#cau-lt([Tìm GTLN — GTNN của $y = sqrt(2x - x^2)$.], loi-giai: [...])
+
+// VDTT — vận dụng thực tế: thẻ "Vận dụng 1" tím, hình thức như TL
+#cau-vdtt([Một chiếc hộp...], loi-giai: [Gọi $x$... \ Vậy...])
+```
+
+Cả 7 dạng đều có `loi-giai:`; trong slide hoạt hình dùng thêm
+`lo-da:` (bước lộ đáp án) và `lo-giai:` (bước lộ lời giải, mặc định
+cùng bước với đáp án). Số thứ tự câu/ví dụ/luyện tập **không đổi**
+qua các bước hoạt hình.
+
+Khung công thức: `#cong-thuc[$S = pi r^2$]` (cùng họ với `#dinh-nghia`, `#dinh-ly`).
+
+## 8. Hoạt hình xuất hiện từng bước
+
+Nguyên lý như Beamer: slide có `so-buoc: n` được in thành `n` trang PDF —
+khi trình chiếu, bấm phím chuyển trang là nội dung "hiện dần".
+Chân trang đánh số theo slide (không tăng theo bước).
+
+```typst
+#slide(tieu-de: [Ví dụ], so-buoc: 3)[
+ Nội dung luôn hiển thị.
+ #lo(2)[Hiện từ bước 2 — khi chưa hiện KHÔNG chiếm chỗ (như \pause của LaTeX
+ beamer; nhờ vậy slide dài không sinh trang trắng đệm).]
+ #lo(2, giu-cho: true)[Ẩn nhưng giữ chỗ — chỉ dùng khi slide chắc chắn gọn 1 trang.]
+ #chi(2)[Chỉ hiện đúng ở bước 2 (mặc định giữ chỗ).]
+ #chi(3, giu-cho: false)[Chỉ hiện ở bước 3, không giữ chỗ.]
+ #an(3)[Hiện từ đầu rồi BIẾN MẤT ở bước 3 (hiệu ứng "thoát" của PowerPoint;
+ mặc định không giữ chỗ, nội dung dưới dồn lên).]
+ #hien-khoang(2, 4)[Chỉ hiện ở bước 2–3, biến mất từ bước 4.]
+ #tung-buoc([ý 1 — bước 2], [ý 2 — bước 3]) // danh sách hiện dần
+ #buoc(hien-dan: true, [Bước giải 1...], [Bước giải 2...])
+]
+```
+
+`#an(n)` là bản đối xứng của `#lo(n)`: `#lo` cho nội dung **hiện ra**, `#an`
+cho nội dung **biến mất**. Ghép hai lệnh để **thay thế** phần tử tại chỗ — hiện
+nội dung tạm rồi xoá đi, cho nội dung mới hiện lên cùng bước:
+
+```typst
+#slide(tieu-de: [Thay thế], so-buoc: 3)[
+ #an(3)[$x = 1$ (giá trị tạm)] // biến mất ở bước 3
+ #lo(3)[$x = 2$ (giá trị đúng)] // hiện lên ở bước 3
+]
+```
+
+Bản in (`dethi`/`loigiai`) hiện hết như `#lo`; muốn bản in chỉ giữ trạng thái
+cuối thì đặt phần biến mất trong `#chi`.
+
+Với câu hỏi, dùng `lo-da` để **đề hiện trước, đáp án lộ sau**:
+
+```typst
+#slide(tieu-de: [Kiểm tra], so-buoc: 2)[
+ #cau-mc([Đề...?], ($1$, $2$, $3$, $4$), dap-an: "B", lo-da: 2)
+]
+```
+
+Bước 1 chiếu đề cho học sinh làm, bấm một cái → phương án đúng được tô xanh.
+(Ở beamer công tắc đáp án đã bật sẵn; chỉ cần `#bat-dap-an()` cho bản in A4.)
+
+`#lo` dùng được với MỌI nội dung — ví dụ hiện dần lời giải khảo sát:
+
+```typst
+#slide(tieu-de: [Khảo sát hàm số], so-buoc: 4)[
+ #vi-du[Khảo sát $y = -x^3 + 3x$.] // bước 1: đề
+ #lo(2)[#loi-giai[$y' = -3x^2 + 3$]] // bước 2: đạo hàm
+ #lo(3)[
+ #bbt(
+ x: ($-oo$, $-1$, $1$, $+oo$),
+ dau: ("", "-", "0", "+", "0", "-", ""),
+ gia-tri: ($+oo$, $-2$, $2$, $-oo$),
+ huong: ("xuong", "len", "xuong"),
+ )
+ ] // bước 3: bảng biến thiên
+ #lo(4)[#figure(do-thi-bac-ba(-1, 0, 3, 0))] // bước 4: đồ thị
+]
+```
+
+### Lời giải dài — `#sang-man` và tự ngắt màn
+
+Trong `loi-giai:` của 8 dạng câu hỏi, đặt `#sang-man` trên **một dòng riêng**
+để đẩy phần sau đó sang một slide mới mang nhãn "Hướng dẫn giải (tiếp)".
+Nhớ dấu `\` ở **cuối dòng chữ ngay trước** dấu này. Bản in A4 bỏ qua nó.
+
+```typst
+loi-giai: [
+ a) Hàm số đồng biến trên $(0; 2)$. \
+ Cực đại tại $x = 2$. \
+ #sang-man \
+ b) Xét $f'(x) = 0 <=> x = 1$.
+],
+```
+
+Quên chèn mà lời giải vẫn cao hơn thân slide thì thư viện **tự đo và tự
+ngắt**: dòng nào làm tràn sẽ được đẩy sang màn "(tiếp)". Nhờ vậy đề bài không
+còn bị in lại xen giữa các trang lời giải — mỗi bước hoạt hình vốn in slide
+lại từ đầu, nên một slide tràn trang sẽ đẻ ra trang "đề" lặp. Đề dài tới mức
+không dòng lời giải nào lọt thì slide đầu chỉ hiện đề.
+
+```typst
+#tu-ngat-man(false)
+```
+
+## 9. Thanh điều hướng & mục lục
+
+- **Đầu trang mỗi slide** (beamer): dải màu đậm phía trên thanh tiêu đề luôn
+ hiện **tên bài học** (`tieu-de-ngan`, mặc định là `tieu-de`) bên trái và
+ tên mục hiện tại bên phải.
+- **Chân trang mỗi slide** là thanh điều hướng: bên trái là tên các phần
+ (bấm để nhảy tới đầu phần; phần hiện tại in đậm), giữa là dãy **chấm tròn**
+ — mỗi chấm một slide của phần hiện tại, chấm đặc là slide đang xem,
+ bấm chấm nào nhảy tới slide đó.
+- `#muc(ngan: [Phần 1])[Phần 1. Tên đầy đủ...]` — `ngan` là nhãn rút gọn
+ hiển thị trên thanh điều hướng.
+- `#muc-luc()` (đặt sau trang bìa) — slide mục lục tự động liệt kê mọi phần
+ và tiêu đề mọi slide, bấm vào tiêu đề nào nhảy thẳng tới slide đó.
+
+Liên kết hoạt động trong mọi PDF reader (SumatraPDF, Edge, Adobe...).
+
+## 10. Một file nguồn — ba kiểu PDF (hồ sơ hiển thị)
+
+Soạn đề MỘT LẦN trong file kiểu `de-mau.typ`, xuất được 3 bản:
+
+| Hồ sơ | Kết quả |
+| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `"dethi"` | Đề thi A4: MC/TF/SA/TL**ẩn** đáp án + lời giải; ví dụ vẫn hiện lời giải; phần Họ tên / Số báo danh (hoặc Lớp) / Mã đề **chỉ hiện khi khai báo** `hien-ho-ten` / `sbd` / `hien-ma-de` |
+| `"loigiai"` | Bản A4 đáp án: mọi câu**hiện** lời giải, tô đáp án đúng |
+| `"beamer"` | Trình chiếu:**mỗi câu một slide** — hiện đề → lời giải hiện dần theo từng dấu `\` → cuối cùng đánh dấu đáp án |
+
+```typst
+#import "@preview/conic-toan:0.3.4": *
+#let ho-so = sys.inputs.at("ho-so", default: "dethi")
+
+#show: de-toan.with(ho-so: ho-so, tieu-de: [ĐỀ KIỂM TRA...],
+ mon: [MÔN TOÁN 12], thoi-gian: "90 phút", truong: [...], ma-de: "101",
+ ngay: "30/12/2026", // bản dethi in "Ngày kiểm tra: ..." dưới dòng thời gian
+ hien-ho-ten: true, // 3 dòng thông tin thí sinh CHỈ hiện khi khai báo:
+ sbd: "sbd", // sbd: "sbd"=Số báo danh | "lop"=Lớp | none=ẩn
+ hien-ma-de: true, // hien-ho-ten / hien-ma-de: bật ô tương ứng
+ ti-le-chu: 1.0, // hệ số phóng cỡ chữ thân — dùng chung cho cả 3 hồ sơ
+ gian-dong: 1.0, // hệ số GIÃN DÒNG — dùng chung cho cả 3 hồ sơ (xem §Giãn dòng)
+ mau-cong-thuc: auto) // màu công thức $...$; auto = đen (thừa kế). Đặt màu để nhuộm cả bài
+
+// Gọi thẳng #vd/#tn/#ds/#tln/#tl/#hd/#lt/#vdtt/#phan — các hàm tự nhận
+// biết chế độ, không cần khai báo tao-cau-hoi (dòng cũ vẫn chạy được).
+
+#phan([PHẦN I. Trắc nghiệm], ngan: [Phần I])
+#tn([Đề...?], ($1$,$2$,$3$,$4$), dap-an: "B",
+ loi-giai: [Bước 1... \ Bước 2... \ Chọn *B*.],
+ tieu-de: [Cực trị]) // tiêu đề slide, chỉ dùng ở beamer
+
+#hd([Hoạt động khám phá...], loi-giai: [a) ... \ b) ...],
+ tieu-de: [HĐ1: Nhận biết]) // câu hoạt động — thẻ "HĐ1"
+#lt([Bài luyện tập...], loi-giai: [$y' = ...$ \ Vậy...],
+ tieu-de: [Luyện tập 1]) // câu luyện tập — thẻ "Luyện tập 1"
+#vdtt([Bài toán thực tế...], loi-giai: [Gọi $x$... \ Vậy...],
+ tieu-de: [Vận dụng]) // câu vận dụng — thẻ "Vận dụng 1"
+#kham-pha([Từ định lí côsin, hãy viết công thức tính $cos A$...]) // thẻ "Khám phá" (không số)
+#trai-nghiem([Vẽ một tam giác $A B C$, đo các cạnh và góc $A$...]) // thẻ "Trải nghiệm"
+#thao-luan([Liệu $sin A$ và diện tích $S$ có tính được theo các cạnh?]) // thẻ "Thảo luận"
+```
+
+Xuất cả 3 bản không cần sửa file:
+
+```
+typst compile --input ho-so=dethi de-mau.typ de-thi.pdf
+typst compile --input ho-so=loigiai de-mau.typ dap-an.pdf
+typst compile --input ho-so=beamer de-mau.typ trinh-chieu.pdf
+```
+
+Quy ước: trong `loi-giai:` của câu hỏi, **mỗi dấu `\` (xuống dòng) là một
+bước xuất hiện** khi trình chiếu; ở bản A4 chúng chỉ là xuống dòng thường.
+
+### Giãn dòng — `gian-dong`
+
+Khi một dòng có phân số, căn thức hoặc chỉ số **nhiều tầng**
+(`$-(11 pi)/5 = -(11 pi)/5 dot (180/pi) degree$`), tử số của dòng dưới dễ
+dính — thậm chí chồng — vào dòng trên. `gian-dong` là **hệ số nhân** vào
+khoảng cách dòng: `1.0` = mốc mặc định, `1.3` = giãn thêm 30%, `0.9` = thu
+lại 10%. Bốn cách dùng, phạm vi từ rộng đến hẹp:
+
+```typst
+// 1) TOÀN TÀI LIỆU — khai ở dòng #show
+#show: bai-giang.with(tieu-de: [CHƯƠNG I], gian-dong: 1.25)
+#show: de-toan.with(ho-so: ho-so, ma-de: "0101", gian-dong: 1.25)
+
+// 2) ĐỔI GIỮA BÀI — áp cho các slide + khung lời giải PHÍA SAU
+#gian-dong(1.4)
+... // phần này giãn 1.4
+#gian-dong(1.0) // trả về mốc mặc định
+
+// 3) RIÊNG MỘT CÂU — tham số gian-dong: của #vd/#tn/#ds/#tln/#tl/#hd/#lt/#vdtt
+#tn([Đề...?], ($1$, True($2$), $3$, $4$),
+ gian-dong: 1.5,
+ loigiai: [$-(11 pi)/5 = -396 degree$. \ Vậy...])
+
+// 4) RIÊNG MỘT KHỐI nội dung bất kì
+#voi-gian-dong(1.7)[
+ Đoạn này giãn 1.7; phần trước và sau không đổi.
+]
+```
+
+Mốc `1.0` ứng với leading gốc của từng hồ sơ (trình chiếu `0.62em` · A4 bài
+giảng `0.6em` · A4 đề thi `0.65em`) nên **một hệ số dùng chung được cho cả 3
+bản PDF**, và đặt `1.0` thì bố cục/số trang y như trước.
+
+Hệ số này giãn **cả hai** loại khoảng cách: giữa các dòng trong cùng một đoạn
+(nối bằng `\`) và giữa hai đoạn (chỗ để **dòng trống**, mốc `1.2em`). Trước
+08/2026 chỉ giãn loại thứ nhất, nên nội dung có dòng trống trông như
+`voi-gian-dong` "chỉ ăn đoạn đầu rồi mất tác dụng".
+
+Khung lời giải hiện dần ở beamer (`giai-buoc`) vốn đã rộng hơn thân slide
+(mốc `0.95em`); hệ số `gian-dong` nhân thêm vào mốc đó. Muốn ấn định độ dài
+tuyệt đối thì dùng `giai-buoc(..., gian: 1.2em)`.
+
+Từ 08/2026 hệ số này còn giãn **khoảng cách giữa các hàng phương án** của
+`#tn`, giữa các ý của `#ds`, giữa các item của `cot-item`, và **khoảng cách từ
+đề xuống phương án** — trước đây nó không với tới những chỗ đó (chúng nằm trong
+ô của `grid` hoặc là khoảng trắng cố định, không phải khoảng cách dòng). Hệ số
+`1.0` vẫn cho đúng số cũ nên bài cũ không đổi bố cục.
+
+Lưu ý về vai trò: từ khi có `cao-that` (mục dưới), **`gian-dong` không còn là
+thứ để chữa dính chữ** — chữ dính đã được lib tự lo. `gian-dong` nay chỉ để
+làm cả bài thoáng hơn hoặc chặt lại theo ý người soạn.
+
+File thử: `thu-gian-dong.typ`.
+
+### Chiều cao thật của công thức trong dòng — `cao-that`
+
+Typst đóng khung công thức **trong dòng** theo số đo phông chữ (cap-height →
+đường chân chữ) chứ không theo nét vẽ: `measure($1/2$)` và `measure($0,5$)`
+cho **cùng** một chiều cao, trong khi phân số vẽ ra cao gần gấp đôi và tràn cả
+trên lẫn dưới khung. Vì thế phân số hay dính vào dòng trên, và trong ô của
+`grid` (phương án `#tn`, ý `#ds`, `cot-item`) thì đè hẳn sang hàng trên —
+`gian-dong` không chữa được vì nó chỉ chạm khoảng cách dòng.
+
+Lib so nét vẽ thật với **nét chữ thường** (chữ thường cũng tràn khỏi khung mà
+không ai kêu, vì `leading` đã chừa chỗ), rồi chèn một **cột chống vô hình rộng
+0pt** đúng bằng phần còn thiếu. Dòng và ô tự nới **đúng chỗ cần**; chữ thường
+và công thức thấp (`$x$`, `$0,5$`) không đổi một pt nào. Cơ chế **bật sẵn**
+trong `bai-giang` và `de-toan`, và đúng cả khi tài liệu bật kiểu LaTeX
+`#show math.equation.where(block: false): it => math.display(it)` (phân số to
+đẹp — cũng chính là cách viết làm dính chữ nặng nhất).
+
+```typst
+#cao-that(false) // tắt từ đây trở đi
+#cao-that() // bật lại
+#cao-that(them: 1pt) // nới thêm 1pt mỗi phía cho công thức có tràn
+#kieu-cau-hoi(cao-that: false) // tắt cho cả bài, khai cùng chỗ mau/hinh
+
+// Tài liệu KHÔNG đi qua bai-giang/de-toan thì áp cho một khối:
+#voi-cao-that[
+ Giá trị $11 pi/3$ và $17 pi/4$ trên hai dòng liền nhau. \
+ Dòng này không còn chạm vào dòng trên.
+]
+```
+
+Nhờ nó mà `gian-dong` để `1.0` là đủ; chỉ dùng `gian-dong` khi muốn cả bài
+thoáng hơn, chứ không phải để chữa dính chữ nữa.
+
+File thử: `thu-cao-that.typ`; bảng số đo đối chiếu: `thu-cao-that-chan-doan.typ`.
+
+### Kết thúc đề & bảng đáp án (`#het`, `#bang-dap-an`)
+
+Hai hàm này CHỈ dựng ở bản in A4 (`dethi`/`loigiai`); ở `beamer` tự bỏ qua
+(không sinh slide thừa). Đặt ở CUỐI file, sau câu cuối cùng:
+
+```
+#het() // dòng "––––– HẾT –––––" + 2 dòng ghi chú (căn giữa)
+#het(chu: [THE END], // tuỳ chỉnh chữ giữa
+ ghi-chu: [Không được dùng tài liệu.]) // ghi-chu: none = ẩn 2 dòng dưới
+
+#bang-dap-an() // 3 bảng đáp án: tn (1.C 2.A…), ds (Đ/S vòng tròn),
+ // tln (mỗi ký tự một ô, như phiếu TLN)
+```
+
+`#bang-dap-an` **tự thu thập đáp án MỌI câu `tn`/`ds`/`tln`** trong tài liệu
+(kể cả form cũ `dap-an:` lẫn form mới `True(...)`) — không cần khai báo lại;
+mỗi loại đánh số `1..n` độc lập theo thứ tự xuất hiện. Tham số:
+
+- `ma-de`: `auto` (mặc định) = **tự lấy mã đề khai ở `de-toan(ma-de: …)`** nên
+ gọi `#bang-dap-an()` là đủ, luôn đồng bộ; `none` = tiêu đề "BẢNG ĐÁP ÁN"
+ (không mã đề); hoặc giá trị cụ thể để ghi đè.
+- `tieu-de`: `auto` (theo `ma-de`) | nội dung tuỳ ý | `none` (ẩn tiêu đề).
+- `so-o-tln`: số ô mỗi đáp án trả lời ngắn (mặc định 4; tự nới nếu đáp án dài hơn).
+- `ngat-trang`: `true` (mặc định) = sang trang mới trước bảng.
+
+Đáp án `tln` truyền dạng chuỗi (`"60,7"`), số, hoặc nội dung (`[1160]`) đều
+được — hàm tự tách thành từng ký tự để điền vào ô; nếu không tách được thì in
+nguyên nội dung vào một ô. Để in bảng có điều kiện, bọc trong `#if`:
+`#if muon-in-dap-an { bang-dap-an(ma-de: ma-de) }`.
+
+### Chia cột phần câu hỏi — `#chia-2-cot`, `#chia-2-cot-lech` (08/2026)
+
+Hai lệnh bố cục dùng như **show-rule**: *đặt ở đâu thì áp dụng từ dòng đó trở
+xuống*, đúng lối `#show: de-toan.with(...)` đã quen.
+
+```
+#show: de-toan.with(ho-so: ho-so, tieu-de: [ĐỀ KIỂM TRA])
+
+#show: chia-2-cot // từ đây trở xuống: câu hỏi xếp 2 cột ĐỀU nhau
+#show: chia-2-cot-lech // hoặc: trái = câu hỏi, phải = chỗ HS làm bài
+```
+
+`#chia-2-cot(so: 2, khoang: 18pt, can: true)` — chia phần câu hỏi thành hai cột
+bằng nhau (đặt `so: 3` nếu muốn ba cột), chữ chảy từ cột trái sang cột phải.
+Hai cột được **tự cân bằng chiều cao** (`can: false` để tắt): Typst vốn rót
+đầy cột 1 tới hết chiều cao trang rồi mới sang cột 2, nên đề ngắn sẽ nằm gọn
+một cột và cột 2 để trống — thư viện tự chèn dấu ngắt cột để hai bên đều nhau.
+
+`#chia-2-cot-lech(...)` — cột **trái** chứa câu hỏi, cột **phải** để trống có
+hàng kẻ ngang mờ cho học sinh làm bài (kèm vạch dọc mờ ngăn hai cột). Số hàng
+kẻ tự tính theo chiều cao thật của phần câu hỏi nên không thừa/thiếu giấy.
+
+| Tham số | Mặc định | Ý nghĩa |
+|---|---|---|
+| `rong-trai` | `70%` | **bề rộng cột trái** — nhận tỉ lệ (`60%`) hoặc độ dài (`11cm`) |
+| `khoang` | `10pt` | khe giữa hai cột |
+| `cao-dong` | `9mm` | khoảng cách giữa hai hàng kẻ |
+| `mau` / `day` | `luma(65%)` / `0.4pt` | màu và độ dày hàng kẻ |
+| `ke` | `true` | `false` = cột phải trắng trơn (không kẻ dòng) |
+| `vach-ngan` | `true` | vạch dọc mờ ngăn hai cột |
+| `tieu-de-phai` | `none` | vd `[Bài làm]` — in nhạt ở đầu cột phải |
+
+**Ngừng chia cột** bằng `#thoi-cot()`: phần sau dấu này trở lại nguyên khổ
+giấy. **Bắt buộc** đặt `#thoi-cot()` trước `#het()`/`#bang-dap-an()` — hàm
+`#bang-dap-an` có `#pagebreak` mà lệnh ngắt trang không chạy được bên trong
+cột.
+
+```typst
+#show: chia-2-cot-lech.with(rong-trai: 65%, tieu-de-phai: [Bài làm])
+#tl([Giải phương trình $x^2 - 5x + 6 = 0$.])
+#tl([Tính đạo hàm của hàm số $y = x^3 - 3x + 1$.])
+#thoi-cot() // trở lại nguyên khổ giấy
+#het()
+#bang-dap-an()
+```
+
+Cả hai lệnh cũng dùng được **dạng khối** cho một đoạn: `#chia-2-cot[ ... ]`,
+`#chia-2-cot-lech(rong-trai: 55%)[ ... ]`.
+
+Ghi chú: hai lệnh chỉ tác dụng ở bản in A4 (`dethi`/`loigiai`); bản trình
+chiếu `beamer` tự bỏ qua (mỗi câu vốn đã là một slide riêng). Khi bật hoán vị
+(trộn đề), các câu nằm trong cột **vẫn được trộn** bình thường và bản `dethi`
+với bản `loigiai` vẫn trộn giống hệt nhau. File thử: `thu-chia-cot.typ`.
+
+### Kẻ dòng lấp đầy trang — `#ke-het-trang` (08/2026)
+
+Đặt ở đâu thì kẻ dòng từ chỗ đó xuống **hết trang đó** — số dòng tự tính theo
+chỗ trống còn lại, không phải đếm tay.
+
+```
+#ke-het-trang() // nét chấm, cách 9mm
+#ke-het-trang(cao-dong: 7mm, kieu: none) // nét liền, dòng dày hơn
+#ke-het-trang(chua: 3cm) // chừa 3cm cuối trang
+#ke-het-trang(dai: 50%, them-trang: 1) // nửa bề ngang + kẻ trọn 1 trang nữa
+```
+
+| Tham số | Mặc định | Ý nghĩa |
+|---|---|---|
+| `cao-dong` | `9mm` | khoảng cách giữa hai dòng kẻ |
+| `mau` / `day` | `luma(65%)` / `0.4pt` | màu và độ dày nét |
+| `kieu` | `"dotted"` | `"dashed"`, `"dash-dotted"`, hoặc `none` = nét liền |
+| `dai` | `100%` | bề dài dòng kẻ (`50%` = nửa bề ngang) |
+| `chua` | `0pt` | chừa thêm khoảng trắng ở đáy trang |
+| `them-trang` | `0` | kẻ thêm bấy nhiêu **trang đầy** nữa sau trang hiện tại |
+| `le-tren` / `le-duoi` | `auto` | tự đọc từ lề trang; chỉ đặt tay khi lề khai kiểu lạ |
+
+Chỉ dựng ở bản in A4 (`dethi`/`loigiai`); bản `beamer` tự bỏ qua. File thử:
+`thu-ke-het-trang.typ`.
+
+### Hoán vị — trộn đề (08/2026)
+
+Bật **một công tắc ở đầu file** là trộn được đề: thứ tự câu và thứ tự phương
+án tự đảo, đáp án + bảng đáp án tự chạy theo.
+
+```typst
+#let ho-so = sys.inputs.at("ho-so", default: "dethi")
+#let hoan-vi = false // <- ĐỔI THÀNH true LÀ TRỘN (mặc định false)
+
+#show: de-toan.with(ho-so: ho-so, ma-de: "0101", hoan-vi: hoan-vi)
+```
+
+Nguyên tắc:
+
+- Hoán vị các **câu** trong cùng một **nhóm thể loại**: nhóm `tn` xáo với nhau,
+ nhóm `ds` xáo với nhau, nhóm `tln` xáo với nhau. Câu `tl` **giữ nguyên** thứ tự.
+- "Nhóm" = dãy câu **cùng loại đứng liền nhau** (chỉ cách nhau bằng dòng trống).
+ Gặp `#phan`, một đoạn văn xen giữa hay một câu khác loại là hết nhóm ⇒ câu
+ **không bao giờ nhảy qua tiêu đề phần**.
+- Hoán vị **4 phương án A/B/C/D** của câu `tn`. Các **ý a/b/c/d của câu `ds`
+ giữ nguyên**, không hoán vị (ý sau thường dựa vào ý trước).
+- Chỉ chạy ở bản in A4 (`dethi` / `loigiai`); trình chiếu `beamer` không trộn.
+
+| Tham số của `de-toan` | Ý nghĩa |
+| --- | --- |
+| `hoan-vi: false` | mặc định — giữ nguyên thứ tự soạn |
+| `hoan-vi: true` | trộn cả thứ tự câu lẫn phương án |
+| `hoan-vi: "cau"` | chỉ trộn thứ tự câu |
+| `hoan-vi: "pa"` | chỉ trộn phương án |
+| `mam: auto` | **mã trộn** — `auto` băm từ `ma-de`, nên `0101` và `0102` tự cho hai thứ tự khác nhau; hoặc đặt tay `mam: 7` |
+
+Bộ trộn là **tất định** (LCG có mầm, không dùng ngẫu nhiên thật): cùng một mầm
+luôn cho cùng một thứ tự, nên bản `dethi` và bản `loigiai` biên dịch hai lần
+vẫn trộn **giống hệt nhau** — bảng đáp án luôn khớp.
+
+```sh
+typst compile --input ho-so=dethi de-mau.typ de-0101.pdf
+typst compile --input ho-so=loigiai de-mau.typ dapan-0101.pdf # cùng thứ tự
+```
+
+Muốn ra nhiều mã đề từ MỘT file: đọc mã đề qua `sys.inputs` rồi đổi lúc biên dịch.
+
+```typst
+#let ma = sys.inputs.at("ma", default: "0101")
+#show: de-toan.with(ho-so: ho-so, ma-de: ma, hoan-vi: true)
+```
+
+Hai điều **phải nhớ**:
+
+- Câu `tn` viết theo **form cũ** `dap-an: "B"` (đáp án là chữ cái) **không được
+ trộn phương án** — trộn sẽ lệch. Muốn trộn thì bọc phương án đúng bằng
+ `True(...)`. Lib tự kiểm: câu nào không có `True(...)` thì bỏ qua, không xáo.
+- `khoa-pa: true` ở một câu `tn` ⇒ riêng câu đó giữ nguyên thứ tự phương án
+ (dùng cho câu kiểu "Cả A, B, C đều đúng").
+
+Tài liệu nào không đi qua `de-toan` thì dùng show-rule riêng:
+`#show: hoan-vi-de.with(true)` (hoặc `.with(true, mam: "0102")`).
+
+File thử: `thu-hoan-vi.typ`.
+
+### Form mới của tn/ds/tln — đáp án gắn liền nội dung (khuyến nghị)
+
+Đáp án đúng bọc `True(...)` (bí danh `Dung(...)`) ngay trong danh sách,
+nên **hoán vị phương án không bao giờ lệch đáp án** — nền tảng cho việc
+xáo trộn tạo mã đề. Form cũ (`dap-an:`, `hinh:`, `loi-giai:`, `cot:`)
+vẫn dùng được song song.
+
+```typst
+#tn(
+ [Nội dung đề bài],
+ ( [$A$], True([$B$]), [$C$], [$D$] ), // đáp án đúng bọc True(...)
+ loigiai: [Lời giải chi tiết],
+ // Tuỳ chọn:
+ fig: none, fig-pos: "right", fig-width: 35%, // hình: vị trí right/left/center/top/bottom
+ // ("center" = canh giữa, nằm giữa phần đề
+ // và các phương án),
+ // bề rộng cột hình (auto = ôm hình)
+ cols: 0, // 0 = tự chia cột; 1/2/4 = cố định
+ lines: 0, // số dòng chừa làm bài (chỉ bản in đề)
+ num: auto, // auto = đánh số tự động; số cụ thể = ghi đè (không tăng bộ đếm)
+ prefix: "Câu", // chữ trên thẻ đầu câu
+ boxed: false, // true = đóng khung cả câu
+)
+
+#ds(
+ [Thân câu chung — nêu tình huống / bài toán],
+ (
+ [Phát biểu a — SAI],
+ True([Phát biểu b — ĐÚNG]),
+ True([Phát biểu c — ĐÚNG]),
+ [Phát biểu d — SAI],
+ ),
+ loigiai: [Phân tích từng ý.],
+)
+
+#tln(
+ [Nội dung đề bài],
+ [$6$], // đáp án đặt NGAY SAU đề
+ loigiai: [Lời giải.],
+ show-boxes: true, box-count: 4, // ô điền trên phiếu (bản in đề)
+)
+```
+
+`loigiai:`/`fig:` cũng dùng được cho `#vd/#tl/#hd/#lt/#vdtt` (bí danh của
+`loi-giai:`/`hinh:`), kèm cả `fig-pos:`/`fig-width:` — tám dạng câu nhận cùng
+một bộ tham số hình.
+
+### Lời giải gắn từng ý của `#ds` — an toàn khi trộn đề hoán vị ý
+
+Khối `loigiai:` viết cứng "a) … b) … c) … d) …" sẽ **sai** nếu công cụ trộn
+xáo thứ tự các ý. Đặt lời giải ngay cạnh ý bằng `giai:` thì lời giải đi theo
+ý, và nhãn a) b) c) d) được đánh lại theo thứ tự **sau** hoán vị:
+
+```typst
+#ds([Cho hàm số $y = x^3 - 3x$.],
+ (
+ True([$y' = 3x^2 - 3$], giai: [Đạo hàm của $x^n$ là $n x^(n-1)$.]),
+ False([Hàm số không có cực trị], giai: [$y' = 0$ có hai nghiệm phân biệt.]),
+ True([Đồ thị nhận điểm uốn $I(0; 0)$ làm tâm đối xứng]), // không cần giải riêng
+ [Hàm số đồng biến trên $RR$], // ý sai, viết trần
+ ),
+)
+```
+
+- `True(nd, giai: ...)` — ý ĐÚNG; `False(nd, giai: ...)` — ý SAI có lời giải.
+- Ý sai không cần lời giải thì viết trần `[...]` như cũ; `giai:` là tuỳ chọn,
+ ý nào không khai thì phần lời giải bỏ qua ý đó.
+- Dùng chung với `loigiai:` được: phần chung in **trước** làm dẫn nhập
+ (tập xác định, đạo hàm…), rồi mới tới phần theo từng ý.
+- `khoa-y: true` (và `khoa-pa: true` cho `#tn`) đánh dấu câu **không được
+ hoán vị** — dùng khi ý sau dựa vào kết quả ý trước, hoặc có phương án kiểu
+ "Cả A và B đều đúng". Cờ này được ghi vào metadata `` để công cụ
+ trộn đọc.
+
+Form cũ (khối `loigiai:` chung, ý bọc `True(...)` không có `giai:`) vẫn chạy
+nguyên vẹn — `giai:`/`False`/`khoa-y` chỉ là bổ sung.
+File thử: `thu-ds-giai.typ`.
+
+### Hình ở LỜI GIẢI và trong PHƯƠNG ÁN/Ý (mọi dạng câu)
+
+Hình không chỉ đặt ở đề bài — cả 8 dạng (`vd/tn/ds/tln/tl/hd/lt/vdtt`) đều
+nhận thêm:
+
+```typst
+fig-giai: do-thi-ham(...), // hình kèm LỜI GIẢI (bí danh: hinh-giai)
+fig-giai-pos: "right", // right | left | top | bottom
+fig-giai-width: auto, // auto = ôm hình; hoặc 35%, 5cm...
+```
+
+Bố cục 2 cột giống hình kèm đề; ở beamer hình hiện cùng bước đầu tiên của
+lời giải. Màn `#sang-man` sau muốn có hình thì chèn thẳng trong nội dung màn.
+
+Hình trong PHƯƠNG ÁN (tn) hoặc Ý (ds): phương án là content nên nhúng trực
+tiếp, thường kèm `cols:` cố định:
+
+```typst
+#tn([Đồ thị nào là của $y = x^2$?], (
+ [#do-thi-ham(x => x*x*x, w: 3.2cm)],
+ True([#do-thi-ham(x => x*x, w: 3.2cm)]),
+ [#do-thi-ham(x => -x*x, w: 3.2cm)],
+ [#do-thi-ham(x => 2*x, w: 3.2cm)],
+), cols: 4, loigiai: [Đồ thị hàm bậc hai $y = x^2$ có đỉnh tại gốc toạ độ, bề lõm hướng lên.])
+```
+
+## 11. Giao diện câu hỏi (theme tiền tố)
+
+- Thẻ "Câu X" và thẻ phương án "A.", ý "a)" dùng **chung một màu và một
+ hình dáng**; đổi một lệnh là đồng bộ toàn bài (đặt đầu file, hoặc giữa
+ chừng để đổi từ vị trí đó):
+
+```typst
+#kieu-cau-hoi(mau: rgb("#e67e22"), hinh: "luc-giac", hien-o: true)
+// hinh: "bo-tron" (mặc định) | "chu-nhat" | "luc-giac" | "khong-to"
+// "khong-to": không tô nền, chỉ viền + chữ màu (tiết kiệm mực);
+// riêng hồ sơ DETHI: bỏ luôn viền — "Câu 1." / "A." (ý ds: "a)")
+// in đậm cùng màu, dấu liền kề (kiểu LaTeX).
+// hien-o: false = ẩn ô tick Đ/S (ds có o-tick) và ô điền "Trả lời" (tln)
+// đồng bộ toàn bài — dùng khi HS làm thẳng vào phiếu trả lời.
+// om-hinh: false = TẮT chế độ chữ ôm hình (xem ngay dưới), quay về lối
+// 2 cột cũ từ vị trí này trở đi.
+// eq-trong-dong: false = giữ nguyên công thức khối `$ ... $` trong phương án
+// (xem ngay dưới); mặc định true = ép thành công thức trong dòng.
+```
+
+### Công thức trong phương án luôn nằm TRONG DÒNG (mặc định BẬT)
+
+Viết `$ cases(x > 0, y > 0, x + y < 3) $` (có khoảng trắng sát hai dấu `$`)
+là công thức **trình bày giữa dòng**: Typst tách nó xuống dòng riêng và canh
+giữa, nên hệ 3 bất phương trình trông khác hẳn hệ 2 bất phương trình viết
+`$cases(...)$`. Từ 08/2026 thư viện tự ép mọi công thức khối trong **phương án
+`#tn`** và **ý `#ds`** về dạng trong dòng, nên hai lối viết cho ra cùng một
+bố cục — khỏi phải sửa tay từng file do AI sinh ra.
+
+- Chỉ đụng tới công thức toán; phương án là hình, bảng, danh sách… giữ nguyên.
+- Vì công thức không còn là "khối", dấu chấm cuối phương án cũng được thêm
+ như các phương án chữ (xem `cham-cuoi`).
+- Số cột `cot: auto` đo theo bề rộng TRONG DÒNG ⇒ hệ 3 bpt thường xếp đủ 4 cột.
+- Tắt toàn bài: `#kieu-cau-hoi(eq-trong-dong: false)`; tắt một câu:
+ `#tn(..., trong-dong: false)` / `#ds(..., trong-dong: false)`.
+
+File thử: `thu-eq-trong-dong.typ`.
+
+### Chữ ôm hình (mặc định BẬT)
+
+Hình đặt bên phải/trái mà **lời giải dài hơn hình** thì cột hình sẽ trống
+một khoảng lớn phía dưới. Từ 08/2026 thư viện tự chữa: đo chiều cao hình,
+đặt vừa đủ các **đoạn đầu** cạnh hình, phần còn lại tràn **nguyên bề rộng**
+xuống dưới hình — không phải khai báo gì thêm.
+
+- Chỗ cắt luôn rơi vào ranh giới đoạn văn / công thức tách dòng / bảng /
+ danh sách, nên không bao giờ cắt ngang một công thức.
+- Công thức tách dòng quá rộng so với cột hẹp sẽ được đẩy xuống phần dưới
+ (tràn hết bề rộng) thay vì bị bó trong cột.
+- Nội dung vốn đã thấp hơn hình ⇒ giữ nguyên lối 2 cột canh giữa như cũ.
+- Tắt: `#kieu-cau-hoi(om-hinh: false)` (toàn bài hoặc một đoạn), hoặc
+ `voi-hinh(..., om: false)` khi gọi trực tiếp.
+
+File thử: `thu-om-hinh.typ`.
+
+- **MC tự chia cột**: mặc định `cot: auto` — đo phương án dài nhất rồi tự
+ chọn 4 / 2 / 1 cột; vẫn ép được `cot: 2`.
+- **TF hai kiểu**: mặc định nhãn Đ/S hiện sau mỗi ý; thêm `o-tick: true`
+ để có 2 ô vuông Đ | S dóng thẳng hàng sát lề phải (bản đề thi: ô trống
+ cho học sinh tick; bản đáp án/trình chiếu: tự tick ✓ xanh/đỏ).
+
+### Tự thêm dấu chấm cuối phương án / ý hỏi (07/2026)
+
+Phương án của `#tn` và ý của `#ds` là một CÂU nên phải kết bằng dấu chấm.
+Thư viện tự dò và **thêm `.` khi còn thiếu** (bật sẵn), gõ nhanh không cần
+nhớ:
+
+```typst
+#tn([...], ([$(-oo; 0)$], True([$(-oo; -2)$]), [$(-2; 0)$], [$(-2; +oo)$]))
+// in ra: A (−∞; 0). B (−∞; −2). C (−2; 0). D (−2; +∞).
+```
+
+Quy tắc dò (duyệt ngược nội dung, tìm ký tự có nghĩa cuối cùng):
+
+- đã kết bằng `.` `!` `?` `…` `:` `;` → **giữ nguyên**, kể cả khi có dấu
+ nháy/ngoặc bọc ngoài: `"đúng rồi!"`, `(đã có chấm.)`;
+- kết bằng khối — danh sách `-`/`+`, bảng, hình, `\` xuống dòng, công thức
+ trình bày giữa dòng `$ ... $` → **bỏ qua** (dấu chấm sẽ rơi xuống dòng);
+- còn lại (chữ, số, công thức trong dòng, dấu ngoặc đóng) → **thêm `.`**
+
+Tắt/bật:
+
+```typst
+#kieu-cau-hoi(cham-cuoi: false) // tắt toàn bài (từ vị trí này)
+#tn([Đề bài], ([A], True([B]), [C], [D]), cham: false) // tắt riêng một câu
+#ds([Đề bài], ([ý a], True([ý b])), cham: true) // ép bật riêng một câu
+```
+
+File thử: `thu-cham-cau.typ` (đủ 5 nhóm ca).
+
+### `#cot-item` — không đánh chồng nhãn a), b) (07/2026)
+
+`#cot-item` TỰ đánh nhãn `a)`, `b)`, `c)`… nên **không cần gõ tay**. Nhưng nếu
+nội dung đã có sẵn nhãn (thường gặp khi chép từ SGK hoặc để AI sinh bài), thư
+viện tự nhận ra và **bỏ nhãn tự động ở đúng item đó** — hết cảnh `a) a) Phải
+quay kim phút…`.
+
+```typst
+#cot-item([Phải quay…], [Có bao nhiêu…]) // in: a) Phải quay… b) Có bao nhiêu…
+#cot-item([a) Phải quay…], [b) Có bao nhiêu…]) // in: a) Phải quay… b) Có bao nhiêu…
+```
+
+Nhãn được nhận: `a)` `a.` `a:` `A)` `(a)` `[b]` `1)` `1.` `(2)` `ii)` `IV.`
+(chữ cái đơn, số ≤ 99, số La Mã ≤ xii; sau dấu `.` hoặc `:` phải có khoảng
+trắng). Quy tắc quyết định:
+
+- **mọi** item đều có nhãn tay → bỏ hết nhãn tự động (kể cả khi đánh tiếp
+ `d) e) f)` cho phần sau);
+- chỉ **một vài** item có nhãn tay → chỉ bỏ ở item mà nhãn tay đúng thứ tự của
+ nó, các item còn lại vẫn được đánh (tránh nhận nhầm câu mở đầu kiểu
+ `A. B. C thẳng hàng`);
+- không nhận nhầm `0.5 lít nước`, `12:30`, `$x^2 - 3x = 0$`, `Cho tam giác…`.
+
+Ép đánh nhãn dù item đã có sẵn: `#cot-item(..., do-nhan-tay: false)`;
+bỏ hẳn nhãn: `kieu-nhan: none`. File thử: `thu-cot-item.typ` (8 nhóm ca).
+
+## Mẹo
+
+- Font: mặc định `("Charis SIL", "Noto Serif", "Libertinus Serif", "Times New Roman")`. **Cần CÀI Charis SIL** (miễn phí: software.sil.org/charis) — chưa
+ cài thì tự rơi về font sau trong danh sách.
+ Lý do: dấu CHỒNG tiếng Việt (**Ể Ổ Ữ Ẩ Ấ Ộ**). Libertinus/Times vẽ dấu hỏi
+ nhỏ, nằm NGANG cạnh dấu mũ → nhìn như mất dấu. Noto Serif ổn ở chữ thường
+ nhưng với chữ HOA (tiêu đề đề thi viết hoa toàn bộ) vẫn thu nhỏ dấu hỏi và
+ đẩy lệch sang phải. Charis SIL do SIL thiết kế cho ngôn ngữ nhiều dấu chồng,
+ giữ dấu nguyên cỡ + chồng thẳng ở CẢ chữ hoa lẫn chữ thường.
+ Đổi font: `#show: bai-giang.with(phong: ("Noto Serif",))`.
+ Bề rộng Charis SIL ≈ Libertinus nên số trang không đổi (Noto Serif rộng hơn
+ ~2%, bài dài có thể trôi thêm 1 trang).
+- Xuất PowerPoint không hỗ trợ; trình chiếu trực tiếp file PDF (PDF reader
+ chế độ full screen, hoặc trình chiếu bằng Sumatra/Okular).
+- Mỗi hàm vẽ đều có `mau`, `day` (độ dày), `dut` (nét đứt) để tuỳ biến.
+
+## Giá trị cực trị dạng căn thức (07/2026)
+
+BBT và lời giải khảo sát (`bbt-bac-ba`, `bbt-trung-phuong`, `bbt-huu-ti`,
+`khao-sat-ve-do-thi-ham-*`) nay hiển thị CẢ GIÁ TRỊ cực trị dạng chính xác
+(căn thức/phân số) thay vì số thập phân — ví dụ `y = x³ − 4x² − x + 6` cho
+`x = (4 ± √19)/3` và `y = (−2 ∓ 38√19)/27`. Tính thẳng từ hệ số (không
+nhận dạng số thập phân) nên đúng với mọi mẫu số lớn:
+
+- `so-can-thuc(P, K, n, Q)` (do-thi.typ): in `(P + K√n)/Q` đã rút gọn
+ (gcd, rút thừa số chính phương √96 → 4√6, chuẩn hoá n hữu tỉ
+ √(2/3) → √6/3); hệ số không "đẹp" thì tự rơi về `so-toan` như cũ.
+- `cuc-tri-bac-ba(a, b, c, d)` / `cuc-tri-huu-ti(a, b, c, d, e)`: trả
+ `(x1:, y1:, x2:, y2:)` chính xác (x1 < x2) — dùng thẳng trong bài soạn.
+
+**Cập nhật:** nhãn trên ĐỒ THỊ cũng chính xác như vậy. Đường gióng cực trị,
+đỉnh parabol, điểm uốn, tâm đối xứng, tiệm cận và giao Ox của
+`do-thi-bac-hai`, `do-thi-bac-ba`, `do-thi-trung-phuong`, `do-thi-huu-ti`,
+`do-thi-phan-thuc` lấy nhãn thẳng từ hệ số (hết cảnh `10.48` — nay là
+`283/27`). Muốn ghi khác thì vẫn đặt `cuc-tri: (ten-x: .., ten-y: ..)`.
+Ngoài ra `so-toan` nhận thêm phân số mẫu lớn (tới 400, khớp chặt 1e-9)
+nên các giá trị như `283/27`, `−13/12` hiện đúng ở mọi chỗ.
+
+## Trục số, conic, côtang & hình lục giác/chóp cụt (07/2026)
+
+### Trục số — biểu diễn đoạn/khoảng (`truc-so`)
+
+Vẽ một hoặc NHIỀU đoạn/khoảng/nửa khoảng trên cùng một trục. Mỗi khoảng là
+một tuple `(a, b, kieu)` (hoặc thêm màu: `(a, b, kieu, mau)`):
+
+- `a`, `b`: số, hoặc `"-oo"` / `"+oo"` cho vô cực (a < b).
+- `kieu`: chuỗi 2 kí tự đầu mút — `"[]"` đoạn, `"()"` khoảng, `"[)"` / `"(]"`
+ nửa khoảng. `[ ]` = lấy đầu mút, `( )` = không lấy.
+
+Mặc định GẠCH CHÉO phần loại bỏ (bù của hợp các khoảng) đúng kiểu SGK, kèm
+ngoặc tại đầu mút.
+
+```typ
+#truc-so((-2, 3, "[)")) // [−2; 3)
+#truc-so((-2, 1, "()"), (3, "+oo", "[)")) // hai khoảng, +∞
+#truc-so(("-oo", 0, "(]"), (2, 5, "[]"), dau: "cham") // chấm đặc/rỗng
+```
+
+Tham số: `w`, `h` (1.5cm — chiều cao khung cố định trên trang), `min`/`max`
+(auto), `ten` (nhãn trục, mặc định `$x$`), `mau`, `gach`/`mau-gach`,
+`cao-gach` (2mm — chiều cao dải gạch chéo trên trang), `dau: "ngoac" | "cham"`,
+`moc-phu:` (ghi thêm số), `so`, `co-chu` (13pt — cỡ chữ số tại mốc).
+
+### Conic: hyperbol & parabol
+
+- `hyperbol(a, b)` — vẽ `x²/a² − y²/b² = 1`; kèm sẵn 2 tiệm cận
+ `y = ±(b/a)x` (nét đứt), đỉnh `A₁A₂`, tiêu điểm `F₁F₂` (c = √(a²+b²)).
+ Tắt phần phụ bằng `tiem-can: false`, `dinh: false`, `tieu-diem: false`;
+ đổi tên qua `ten-dinh`, `ten-tieu`.
+- `parabol(p)` — vẽ `y² = 2px` (p > 0 mở phải, p < 0 mở trái); kèm đỉnh O,
+ tiêu điểm `F(p/2; 0)`, đường chuẩn `Δ: x = −p/2` (nét đứt). Tắt qua
+ `tieu-diem: false`, `duong-chuan: false`.
+- `duong-elip(a, b)` — vẽ `x²/a² + y²/b² = 1` (nhập 2 bán trục như hyperbol);
+ kèm 4 đỉnh `A₁A₂B₁B₂` và 2 tiêu điểm trên trục lớn (a ≥ b: `F(±c; 0)`;
+ b > a: `F(0; ±c)`, `c = √|a²−b²|`); `a = b` cho đường tròn. Tên là
+ `duong-elip` (không phải `elip` — `elip` là hàm vẽ elip cơ bản trong `ve.typ`).
+
+```typ
+#duong-elip(4, 2.5) // elip nằm ngang, tiêu điểm trên Ox
+#duong-elip(2, 3.2) // elip đứng, tiêu điểm trên Oy
+```
+
+Nhãn tên hàm nay có **nền trắng** nên đường cong không xuyên qua; vị trí nhãn
+(`goc-ten: auto`) tự né đồ thị. Cơ số mũ/lôgarit hiển thị **số đẹp** (phân số,
+căn): `#do-thi-mu(1/3)` → `y = (1/3)^x`, `#do-thi-log(1/3)` → `y = log_(1/3) x`.
+
+```typ
+#hyperbol(3, 2) // x²/9 − y²/4 = 1
+#parabol(2) // y² = 4x
+#parabol(-1.5) // y² = −3x (mở trái)
+```
+
+### Đồ thị côtang (`do-thi-cot`)
+
+`#do-thi-cot()` — `y = cot x`, tiệm cận đứng tại `x = kπ` (nét đứt tại ±π,
+riêng x = 0 trùng trục Oy). Cùng bộ tham số như `do-thi-tan`
+(`w`, `mau`, `ten`, `goc-ten`, `luoi-o`, `them`). (`do-thi-mu`, `do-thi-log`
+đã có sẵn cho `y = aˣ`, `y = logₐx`.)
+
+### Hình lục giác đều & chóp cụt đều
+
+Nét khuất (đáy sau, cạnh bên/đứng tới đỉnh sau) TỰ vẽ đứt theo hình chiếu.
+
+- `hinh-chop-luc-giac-deu()` — chóp lục giác đều `S.ABCDEF`, có đường cao `SO`.
+- `hinh-lang-tru-luc-giac-deu()` — lăng trụ lục giác đều `ABCDEF.A'B'C'D'E'F'`.
+- `hinh-chop-cut-deu(n: 3 | 4 | 6)` — chóp cụt đều tam/tứ/lục giác. `R` bán
+ kính đáy dưới, `r` đáy trên, `cao` chiều cao; `truc: true` vẽ trục `OO'`.
+ Tên đỉnh tự sinh (A, B, … cho đáy dưới; phẩy cho đáy trên) hoặc truyền
+ `ten:` là mảng 2n nội dung.
+- Bí danh gọn (cùng bộ tham số): `hinh-chop-cut-tam-giac-deu()` (ABC.A'B'C'),
+ `hinh-chop-cut-tu-giac-deu()` (ABCD.A'B'C'D'),
+ `hinh-chop-cut-luc-giac-deu()` (tên gốc, `n: 6` mặc định).
+
+```typ
+#hinh-chop-luc-giac-deu(w: 6.5cm)
+#hinh-lang-tru-luc-giac-deu(w: 6.5cm)
+#hinh-chop-cut-tam-giac-deu()
+#hinh-chop-cut-tu-giac-deu(truc: true)
+#hinh-chop-cut-deu(n: 6)
+```
+
+### Bảng tần số & biểu đồ thống kê (bang-thong-ke.typ + bieu-do-thong-ke.typ)
+
+Dữ liệu vào của bảng và biểu đồ KHỚP NHAU: cùng `moc:` (n+1 mốc số → n nhóm)
+và `tan-so:` — một nguồn số liệu dùng cho cả bảng lẫn biểu đồ. Mọi biểu đồ
+đều có `them: ctx => ...` để vẽ chồng như các hình khác.
+
+```typ
+// 4 bảng tần số bố cục NGANG đúng SGK (don-vi: hiện «tên (đơn vị)» ô góc)
+#bang-tan-so(gia-tri: (0, 1, 2, 3), tan-so: (4, 3, 4, 7), ten-gia-tri: [Số con])
+#bang-ghep-nhom(moc: (150, 155, 160, 165, 170, 175), tan-so: (5, 12, 18, 9, 4),
+ ten-nhom: [Chiều cao], don-vi: [cm])
+// (kèm bang-tan-so-doi / bang-ghep-nhom-doi cho 2 dòng tần số)
+
+// HISTOGRAM — cột dính sát nhau (tần số ghép nhóm, Toán 11–12)
+#bieu-do-tan-so(moc: (150, 155, 160, 165, 170, 175), tan-so: (5, 12, 18, 9, 4),
+ ten-x: [Chiều cao (cm)])
+#bieu-do-tan-so(moc: (150, 155, 160, 165, 170, 175), tan-so: (5, 12, 18, 9, 4),
+ gap-khuc: true) // + gấp khúc tần số chồng lên
+
+// ĐƯỜNG GẤP KHÚC TẦN SỐ đứng riêng (nối trung điểm, kéo dài về 0 hai đầu)
+#da-giac-tan-so(moc: (150, 155, 160, 165, 170, 175), tan-so: (5, 12, 18, 9, 4),
+ keo-dai: true)
+
+// BIỂU ĐỒ CỘT RỜI — giá trị rời rạc: số (đặt đúng hoành độ) hoặc chữ
+#bieu-do-cot(gia-tri: (0, 1, 2, 3, 4), tan-so: (4, 9, 13, 6, 2), ten-x: [Số con])
+#bieu-do-cot(gia-tri: ([Đỏ], [Xanh], [Vàng]), tan-so: (10, 7, 5))
+
+// BIỂU ĐỒ HỘP (box plot) — tứ phân vị Toán 10, gióng nét đứt xuống trục số
+#bieu-do-hop(du-lieu: (3, 4, 4, 5, 6, 7, 8, 9, 10), ten: [Điểm]) // tự tính
+#bieu-do-hop(tom-tat: (150, 158, 163, 168, 175)) // (min, Q1, Q2, Q3, max)
+
+// BIỂU ĐỒ QUẠT TRÒN — chia góc theo %, bắt đầu 12 giờ, chiều kim đồng hồ
+#bieu-do-quat(([Đi bộ], 25), ([Xe đạp], 30), ([Xe máy], 35), ([Khác], 10))
+```
+
+Tham số chung biểu đồ cột/gấp khúc: `w`, `h`, `mau`, `vien`, `ten-x`/`ten-y`,
+`so-dinh` (số trên đỉnh cột), `so-truc`, `buoc-y` (auto = bước đẹp 1/2/5·10ᵏ),
+`luoi-ngang` (lưới ngang mờ), `co-chu`, `them`. Quạt: mỗi mục `(tên, số)` hoặc
+`(tên, số, màu)`; `phan-tram: true` ghi % trong quạt (quạt < 8% ghi gộp bên ngoài).
+
+Hai hàm TRẢ GIÁ TRỊ (không vẽ, dùng thẳng trong lời giải):
+
+- `tu-phan-vi(du-lieu)` → `(min:, q1:, q2:, q3:, max:)` — SGK Toán 10
+ (n lẻ KHÔNG kể Q₂ vào hai nửa).
+- `tu-phan-vi-ghep-nhom(moc, tan-so)` → `(q1:, q2:, q3:)` — nội suy SGK Toán 11.
+
+### Số đặc trưng mẫu số liệu (bieu-do-thong-ke.typ)
+
+Tất cả TRẢ GIÁ TRỊ (không vẽ) — dùng thẳng trong lời giải, in đẹp bằng
+`so-toan`/`so-dep`. KHÔNG ghép nhóm nhận mảng thô HOẶC `gia-tri + tan-so:`
+(bảng tần số); GHÉP NHÓM nhận `(moc, tan-so)` như biểu đồ.
+
+```typ
+// KHÔNG ghép nhóm — thô hoặc bảng tần số (thêm tan-so:)
+#so-trung-binh((1, 2, 2, 3)) // 2
+#so-trung-binh((0, 1, 2), tan-so: (4, 3, 4))
+#mot((1, 1, 2, 3)) // MẢNG các mốt: (1,) — có thể nhiều mốt
+#trung-vi(d) #tu-phan-vi(d) // tu-phan-vi -> (min:, q1:, q2:, q3:, max:)
+#phuong-sai(d) #do-lech-chuan(d) // s² chia n; hieu-chinh: true -> chia n−1
+#khoang-bien-thien(d) #khoang-tu-phan-vi(d)
+
+// GHÉP NHÓM — trung bình/phương sai theo giá trị đại diện, còn lại nội suy SGK
+#so-trung-binh-ghep-nhom(moc, tan-so) #mot-ghep-nhom(moc, tan-so)
+#trung-vi-ghep-nhom(moc, tan-so) #tu-phan-vi-ghep-nhom(moc, tan-so)
+#phuong-sai-ghep-nhom(moc, tan-so) #do-lech-chuan-ghep-nhom(moc, tan-so)
+#khoang-bien-thien-ghep-nhom(moc, tan-so) // mốc phải nhóm cuối − mốc trái nhóm đầu CÓ dữ liệu
+#khoang-tu-phan-vi-ghep-nhom(moc, tan-so)
+```
+
+## Nón & trụ có NÉT KHUẤT TỰ ĐỘNG — kể cả hai khối che nhau (08/2026)
+
+`lib/mat-cong.typ` bổ khuyết cho `da-dien.typ`: engine đa diện chỉ lo được khối
+đa diện LỒI và KHÔNG xử lý được hai khối che nhau. Ở đây dùng một cơ chế duy
+nhất cho cả hai việc — bắn tia từ điểm đang xét về phía người nhìn, tia gặp lòng
+bất kì khối nào (kể cả chính nó) thì đoạn đó vẽ NÉT ĐỨT.
+
+```typ
+// nón có trục là một đường sinh của trụ — đường sinh nón chui vào lòng trụ
+// TỰ chuyển sang nét đứt, không phải cắt tay
+#mat-cong(
+ mat-non(r: 2, cao: 4, mau: red),
+ mat-tru(tam: (0, 2, 0), r: 2, cao: 4, mau: blue),
+ w: 9cm,
+)
+
+#mat-cong(mat-non(r: 2.6, cao: 4), mat-tru(r: 1.1, cao: 2)) // trụ nội tiếp nón
+#mat-cong(mat-non(r: 2, cao: 3.6), hien-khuat: false) // bỏ hẳn nét đứt
+```
+
+Camera MẶC ĐỊNH của engine này là chiếu TRỰC GIAO
+(`chieu-truc-giao(ngang: 15deg, cao: 22deg)`), KHÔNG phải `chieu-xien` như
+`da-dien.typ`: khối vẽ ra NGAY NGẮN — elip đáy có trục lớn nằm ngang, trục
+khối thẳng đứng, đúng lối hình sách giáo khoa, khỏi phải khai `cam:` gì cả.
+Muốn lối chiếu xiên cũ thì khai rõ `cam: chieu-xien()` hoặc `cam: chieu-oxyz()`.
+
+Mô tả khối (TRẢ GIÁ TRỊ): `mat-non(tam:, r:, cao:, nghieng:, huong:, truc:,
+mau:, to:)` và `mat-tru(...)` cùng bộ tham số — `tam` là tâm mặt đáy, `cao` đo
+DỌC TRỤC; `mau`/`to` để `auto` thì lấy theo lệnh vẽ.
+Tiện ích: `dinh-non(k)`, `tron-ngang(tam, r, truc:)`.
+
+**Trục khối đặt nghiêng được.** `nghieng` là góc giữa trục khối và `Oz`,
+`huong` là hướng ngả (đo trong mặt phẳng `Oxy`); hoặc ghi đè thẳng bằng
+`truc: (a, b, c)`. Mặt đáy LUÔN vuông góc với trục, nên trục nghiêng thì mặt
+đáy và đường sinh nghiêng cùng — toàn thân khối nghiêng:
+
+```typ
+#mat-cong(mat-tru(r: 1.2, cao: 5, nghieng: 55deg, huong: 200deg)) // trụ nằm nghiêng
+#mat-cong(mat-non(r: 1.6, cao: 4.5, nghieng: 90deg)) // nón nằm ngang
+```
+
+Mọi công thức trong engine viết theo KHUNG RIÊNG `(u, v, w)` của khối (`w` là
+trục, `(u, v)` là hai phương trong mặt đáy) nên trục đứng chỉ là trường hợp
+riêng `nghieng: 0deg`.
+
+`ve-mat-cong(..khoi, cam:, mau:, day:, to:, hien-khuat:, mau-khuat:, day-khuat:,
+n:, duong:, them:)` nhận `ctx`; `mat-cong(...)` tự tạo khung hình, mọi
+tuỳ chọn khác chảy thẳng xuống `ve-mat-cong`.
+
+Đường vẽ thêm cũng được tự chia liền/đứt — mỗi mục của `duong:` là một dict:
+
+```typ
+#mat-cong(
+ mat-non(r: 2, cao: 4), mat-tru(tam: (0, 2, 0), r: 2, cao: 4),
+ duong: ((pts: tron-ngang((0, 0, 1.6), 1.2), mau: green, day: 0.9pt),),
+)
+```
+
+### Hệ trục Oxyz nghiêng CÙNG góc với khối — `truc:` / `ve-truc-3d`
+
+`oxyz` của `hinh-khong-gian.typ` tự dựng phép chiếu XIÊN riêng. Trong phép chiếu
+xiên, đường tròn nằm ngang chiếu ra elip NGHIÊNG trong khi `Oz` vẫn dựng đứng —
+đáy nghiêng mà trục đứng, nhìn vô lý. Muốn cả khung hình chung MỘT góc nghiêng
+thì dùng camera TRỰC GIAO thật và vẽ hệ trục bằng chính camera đó:
+
+```typ
+#let nghieng = 22deg // góc nhìn từ trên xuống — MỘT biến cho cả khung hình
+#let quay = 15deg // xoay quanh trục đứng, chỉnh cho hai khối tách nhau
+
+#mat-cong(
+ mat-non(r: 2, cao: 4), mat-tru(tam: (0, 2, 0), r: 2, cao: 4),
+ cam: chieu-truc-giao(ngang: quay, cao: nghieng),
+ truc: (x: 2.6, y: 4.4, z: 4.6), // hệ trục vẽ bằng CHÍNH camera đó
+)
+```
+
+Với `chieu-truc-giao`, elip đáy có trục lớn NẰM NGANG (bán trục bé =
+`sin(nghieng)`) và trục khối THẲNG ĐỨNG — đúng như nhìn vật thật.
+ĐÁNH ĐỔI cần biết: bù lại `Ox` và `Oy` đều chếch xuống, không còn `Oy` nằm
+ngang như lối vẽ trục quen thuộc. Không thể có đồng thời cả hai — đó là tính
+chất hình học, không phải lỗi.
+
+`ve-truc-3d(cam, x:, y:, z:, am:, dm:, ten:, ten-goc:, huong-ten:, huong-goc:,
+mau:, day:, cach:)` dùng được với BẤT KÌ camera nào (`chieu-xien`,
+`chieu-oxyz`, `chieu-truc-giao`). `ve-mat-cong` còn có `truoc:` (vẽ trước khối)
+song song với `them:` (vẽ sau khối).
+
+Nếu vẫn muốn dùng `oxyz` sẵn có thì `k` và `goc` của `oxyz` PHẢI trùng với
+`chieu-oxyz(k:, goc:)`, nếu không khối sẽ lệch khỏi hệ trục:
+
+```typ
+#oxyz(x: 2.6, y: 4.4, z: 4.6, k: 0.7, goc: 30deg, them: (ctx, t3) => {
+ ve-mat-cong(ctx, mat-non(r: 2, cao: 4), mat-tru(tam: (0, 2, 0), r: 2, cao: 4),
+ cam: chieu-oxyz(k: 0.7, goc: 30deg))
+})
+```
+
+Phép thử che khuất là GIẢI TÍCH (giao tia với mặt đáy là bậc nhất, với mặt bên
+là bậc hai), KHÔNG quét mẫu — nên không bỏ sót nét khuất dù dây cung qua khối
+rất ngắn. Sai số duy nhất còn lại nằm ở mắt lưới của đường được vẽ, chỉnh bằng
+`n:`.
+
+GIỚI HẠN, đọc trước khi dùng: chưa có mặt cầu; đường sinh biên CỐ Ý không để
+chính khối của nó che (tia bắn từ đúng đường biên là tiếp tuyến, xét ở đó sẽ
+chập chờn làm nét biên lúc liền lúc đứt).
+
+## Engine đa diện tổng quát, mặt phẳng Oxyz & thiết diện (07/2026)
+
+`lib/da-dien.typ` — nhập DANH SÁCH ĐỈNH 3D + DANH SÁCH MẶT là vẽ được bất kì
+khối đa diện LỒI: tự chiếu, tự phân biệt mặt trước / mặt sau, tự vẽ nét liền và
+nét khuất, tự tô mặt theo độ sâu. Thứ tự đỉnh trong mỗi mặt KHÔNG cần theo
+chiều nào (pháp tuyến được lật ra ngoài theo tâm khối).
+
+```typ
+// khối dựng sẵn — bung bằng toán tử `..` (trả (dinh:, mat:, ten:))
+#da-dien(..khoi-chop-deu(n: 4, R: 1.9, cao: 3.6), to: blue.lighten(86%))
+#da-dien(..khoi-lang-tru-deu(n: 6, R: 1.8, cao: 3.4))
+#da-dien(..khoi-chop-cut-deu(n: 4, R: 2, r: 1.1, cao: 3))
+#da-dien(..khoi-hop-chu-nhat(dai: 4, rong: 2.6, cao: 3))
+#da-dien(..khoi-lap-phuong(a: 3))
+#da-dien(..khoi-tu-dien-deu(a: 3))
+#da-dien(..khoi-bat-dien-deu(a: 3))
+#da-dien(..khoi-hop((0,0,0), (3,0,0), (0.6,2.2,0), (0.9,0.4,2.8))) // hộp lệch
+#da-dien(..khoi-chop(day, S)) // đáy = mảng điểm 3D bất kì
+#da-dien(..khoi-lang-tru(day, v)) // đáy tịnh tiến theo vectơ v
+#da-dien(..khoi-chop-cut(day, day-tren)) // hai đáy cùng số đỉnh
+
+// tự khai đỉnh + mặt (mỗi mặt là mảng CHỈ SỐ đỉnh)
+#da-dien(
+ dinh: ((0,0,0), (3.2,0,0), (0,3,0), (0,0,3)),
+ mat: ((0,1,2), (0,1,3), (0,2,3), (1,2,3)),
+ ten: ($O$, $A$, $B$, $C$), to: blue.lighten(88%),
+)
+```
+
+Tham số của `da-dien` / `ve-da-dien`: `ten` (mảng nhãn theo thứ tự đỉnh),
+`huong` (auto = tự đặt nhãn vào KHE GÓC RỘNG NHẤT giữa các cạnh nên không đè
+lên nét), `to` (màu tô các mặt trước), `to-mat` (mảng màu riêng từng mặt,
+`auto` = theo `to`), `hien-khuat: false` (bỏ nét khuất, ẩn luôn đỉnh chỉ thuộc
+mặt sau), `mau-khuat` / `day-khuat`, `hien-dinh`, `bk`, `w`, `le`,
+`them: (ctx, p) => ...` (`p` chiếu `(x, y, z)` → điểm 2D, dùng được với MỌI hàm
+vẽ phẳng). Bản `ve-da-dien(ctx, ...)` vẽ vào khung có sẵn, `da-dien(...)` tự
+tạo khung.
+
+**Camera** — `cam:` nhận một trong:
+
+```typ
+cam: chieu-xien(goc: 40deg, k: 0.5) // mặc định, khớp hình dựng sẵn
+cam: chieu-truc-giao(ngang: 65deg, cao: 22deg) // camera Euler thật
+cam: chieu-oxyz(k: 0.55, goc: 35deg) // khớp hệ trục #oxyz
+cam: P => (P.at(1), P.at(2)) // hoặc hàm chiếu tuỳ ý
+```
+
+Hướng nhìn (để biết mặt nào là mặt trước) được SUY RA từ ma trận chiếu, nên
+hàm chiếu tuỳ ý cũng phân biệt nét khuất đúng.
+
+**Mặt phẳng trong Oxyz** — gọi trong `oxyz(them: (ctx, t3) => ...)`; phần trục
+toạ độ nằm SAU mặt phẳng TỰ vẽ đứt:
+
+```typ
+#oxyz(x: 5, y: 6, z: 5, them: (ctx, t3) => {
+ // x/4 + y/5 + z/3.5 = 1, cắt ba trục tại A(4,0,0), B(0,5,0), C(0,0,3.5)
+ mat-phang-oxyz(ctx, t3, 4, 5, 3.5, ten-dinh: true, ten: $(P)$)
+ // mặt phẳng lơ lửng: tâm + hai nửa-vectơ (4 đỉnh = tam ± u ± v)
+ mat-phang-bh(ctx, t3, (2.2, 2.8, 2.6), (2.4, 0, 0), (0, 2.6, 0),
+ ten: $(alpha)$, truc: ((0, 5), (0, 6), (0, 5)))
+})
+```
+
+`truc:` khai phạm vi ba trục để xử lí nét đứt (`auto` của `mat-phang-oxyz` =
+từ gốc tới mốc cắt trục; `none` = không xử lí).
+
+**Thiết diện khối lồi**:
+
+```typ
+#let ch = khoi-chop-deu(n: 4, R: 1.9, cao: 3.6)
+#da-dien-thiet-dien(..ch, mp: mp-qua-3-diem(A, M, N), td: (mau: red))
+// mặt phẳng: mp-qua-3-diem(A, B, C) · mp-qua-phap(P, n) · mp-cat-truc(a, b, c)
+// mp-song-song(mp, P)
+// thiet-dien(dinh, mat, mp) TRẢ VỀ mảng điểm 3D đã sắp theo VÒNG
+// ve-thiet-dien(ctx, dinh:, mat:, mp:) vẽ vào khung có sẵn
+```
+
+Cạnh thiết diện nằm trên mặt THẤY vẽ liền, nằm trên mặt KHUẤT vẽ đứt. Mặt
+phẳng đi qua đỉnh của khối vẫn xử lí đúng.
+
+Vectơ 3D để dựng điểm: `v3-cong`, `v3-tru`, `v3-nhan`, `v3-vo-huong`,
+`v3-co-huong`, `v3-dai`, `v3-chuan`, `trung-diem-3d(A, B)`, `chia-3d(A, B, t)`,
+`tam-3d(ds)`, `day-deu(n, R, z: 0)`, `phap-da-giac(ds)`, `phan-tich-khoi(dinh, mat)`.
+
+**Điểm phụ và đoạn phụ trong khối** — `diem:` và `duong:` (có ở cả `da-dien`,
+`ve-da-dien` và `da-dien-thiet-dien`):
+
+```typ
+#let cd = khoi-chop-deu(n: 4, R: 1.9, cao: 3.6)
+#let (A, B, C, D, S) = cd.dinh
+#let O = tam-3d((A, B, C, D)) // tâm đáy
+#let M = trung-diem-3d(S, B) // trung điểm SB
+#let N = diem-canh(cd.dinh, 2, 4, t: 0.6) // trên cạnh nối đỉnh #2 với #4
+#let H = hinh-chieu-3d(A, S, C) // chân đường vuông góc từ A xuống SC
+
+#da-dien(
+ ..cd, to: blue.lighten(92%),
+ diem: ((O, $O$), (M, $M$), (N, $N$), (H, $H$, auto, red)),
+ duong: (
+ (S, O, (mau: red, vuong: A, ten: $h$, tai: 0.22, huong: "right")),
+ (A, H, (mau: red)),
+ (A, M),
+ ),
+)
+```
+
+Mỗi mục của `diem:` là `P` · `(P, ten)` · `(P, ten, huong)` · `(P, ten, huong, mau)`. Điểm nằm **trên một cạnh** của khối thì nhãn TỰ đặt vuông góc với cạnh
+đó (đúng lối sách); điểm khác thì nhãn toả ra xa tâm hình chiếu.
+
+Mỗi mục của `duong:` là `(A, B)` hoặc `(A, B, tuỳ-chọn)`, tuỳ-chọn là dict nhận
+`mau`, `day`, `ten`, `tai`, `huong`, `cach`, `dut` (ép kiểu nét), `hien-khuat`,
+`mau-khuat`, `day-khuat`, `vuong` (một điểm $C$ — vẽ ký hiệu góc vuông tại $B$,
+giữa $BA$ và $BC$) và `r` (cỡ ký hiệu). Phần đoạn **bị khối che tự vẽ đứt**,
+phần thấy vẽ liền — một đoạn có thể vừa liền vừa đứt (đường cao $SO$ thấy ở
+ngoài rồi chui vào trong khối). Phép kiểm là CHÍNH XÁC, không quét mẫu: xét tia
+từ điểm về phía người nhìn có cắt khối lồi hay không.
+
+Với `hien-khuat: false`, điểm bị khối che, ký hiệu góc vuông và nhãn nằm ở chỗ
+bị che đều được ẩn theo.
+
+Tiện ích dựng điểm: `diem-canh(dinh, i, j, t: 0.5)` (chia cạnh nối hai đỉnh),
+`hinh-chieu-3d(P, A, B)` (chân đường vuông góc hạ xuống đường thẳng $AB$),
+`hinh-chieu-mp(P, mp)` (hình chiếu lên mặt phẳng), `tam-3d(ds)` (tâm của mảng
+điểm — dùng lấy tâm đáy).
+
+GIỚI HẠN: quy tắc nét khuất chỉ đúng với khối LỒI (đủ cho toàn bộ THPT). Khối
+KHÔNG lồi, hai khối che nhau, hoặc mặt cong (cầu/trụ/nón) thì dùng các hàm cũ
+trong `hinh-khong-gian.typ`. File thử: `thu-da-dien.typ`.
+
+## Tính toán trong không gian Oxyz (`lib/oxyz-toan.typ`) (07/2026)
+
+Nhóm hàm **tính** (không vẽ): mọi hàm trả về **giá trị** (số, điểm, dict) hoặc
+**nội dung toán đã định dạng** (nhóm `pt-*` và `hien-*`), gọi thẳng, không cần
+`ctx`.
+
+Kiểu dữ liệu dùng chung:
+
+| Đối tượng | Cách viết |
+| --------------- | ---------------------------------------------------- |
+| điểm / vectơ | `(x, y, z)` |
+| mặt phẳng | `(a: , b: , c: , d: )` ⇔ `ax + by + cz + d = 0` |
+| đường thẳng | `(P: điểm đi qua, u: vectơ chỉ phương)` |
+| mặt cầu | `(I: tâm, R: bán kính)` |
+
+Các hàm nhận mặt phẳng cũng **nhận luôn dạng `(n: , d: )`** của `da-dien.typ`
+(`mp-qua-3-diem`, `mp-cat-truc`…), nên hai nhóm dùng lẫn được.
+
+### Vectơ
+
+```typst
+#let A = (1, 2, -1)
+#let B = (2, -1, 3)
+#let C = (-4, 7, 5)
+
+vecto-3d(A, B) // toạ độ AB = B − A → (1, -3, 4)
+do-dai-vecto(vecto-3d(A, B)) // độ dài (số thực)
+tich-vo-huong(u, v) // u · v
+tich-co-huong(u, v) // [u, v]
+tich-hon-tap(u, v, w) // [u, v] · w (định thức cấp ba)
+cos-goc-vecto(u, v) · goc-vecto(u, v) // số thực · kiểu angle
+cung-phuong(u, v) · vuong-goc(u, v) · dong-phang(u, v, w) // true/false
+```
+
+Các phép cộng/trừ/nhân số có sẵn từ `da-dien.typ`: `v3-cong`, `v3-tru`,
+`v3-nhan`, `v3-chuan`, `v3-dai`.
+
+### Điểm — khoảng cách, điểm đặc biệt của tam giác
+
+```typst
+khoang-cach-3d(A, B) // = do-dai-doan-3d(A, B)
+trung-diem-3d(A, B) · chia-3d(A, B, t)
+trong-tam-3d(A, B, C) // nhận số điểm tuỳ ý
+truc-tam-3d(A, B, C) // hệ thức Euler H = A + B + C − 2O
+tam-ngoai-tiep-3d(A, B, C) · ban-kinh-ngoai-tiep-3d(A, B, C)
+tam-noi-tiep-3d(A, B, C) · ban-kinh-noi-tiep-3d(A, B, C)
+tam-bang-tiep-3d(A, B, C) // bàng tiếp TRONG GÓC A
+```
+
+### Diện tích — thể tích
+
+```typst
+dien-tich-tam-giac-3d(A, B, C) dien-tich-hbh-3d(A, B, C)
+the-tich-tu-dien(A, B, C, D) the-tich-hinh-hop(A, B, C, D)
+bon-diem-dong-phang(A, B, C, D)
+```
+
+### Mặt phẳng
+
+```typst
+mat-phang-qua-phap(A, (2, -3, 1)) // qua 1 điểm + 1 vectơ pháp tuyến
+mat-phang-qua-3-diem(A, B, C)
+mat-phang-doan-chan(p, q, r) // x/p + y/q + z/r = 1
+mat-phang-trung-truc(A, B)
+mat-phang-song-song(mp, A) // qua A, song song mp
+mat-phang-vuong-goc-duong(A, d) // qua A, vuông góc đường thẳng d
+mat-phang-qua-2-phuong(A, u, v)
+
+#pt-mat-phang(mat-phang-qua-3-diem(A, B, C)) // $19x + 13y + 5z − 40 = 0$
+```
+
+Hệ số **tự rút gọn về nguyên tố cùng nhau**, hệ số khác 0 đầu tiên mang dấu
+dương. Đổi tên ẩn: `pt-mat-phang(P, an: ("X", "Y", "Z"))`.
+
+```typst
+phap-tuyen-mp(P) the-vao-mp(A, P) // ax₀+by₀+cz₀+d
+khoang-cach-diem-mp(A, P) goc-2-mp(P, Q)
+hinh-chieu-len-mp(A, P) doi-xung-qua-mp(A, P)
+vi-tri-2-mp(P, Q) // "trùng nhau" | "song song" | "cắt nhau"
+```
+
+### Đường thẳng
+
+```typst
+#let d1 = duong-thang-qua-2-diem(A, B)
+#let d2 = duong-thang-qua-vtcp((1, 2, 3), (2, 0, -1))
+duong-thang-vuong-goc-mp(A, P)
+
+#pt-tham-so(d1) // hệ cases(x = …, y = …, z = …), đổi tham số: t: "k"
+#pt-chinh-tac(d1) // thành phần chỉ phương bằng 0 tự tách ra sau dấu phẩy
+```
+
+```typst
+khoang-cach-diem-duong(A, d) khoang-cach-2-duong(d1, d2)
+hinh-chieu-len-duong(A, d) doi-xung-qua-duong(A, d)
+goc-2-duong(d1, d2) goc-duong-mp(d, P)
+vi-tri-2-duong(d1, d2) // "trùng nhau" | "song song" | "cắt nhau" | "chéo nhau"
+giao-2-duong(d1, d2) giao-duong-mp(d, P) giao-2-mp(P, Q) // none nếu không có
+vtcp-dep(u) // rút gọn vectơ chỉ phương về nguyên, dấu đầu dương
+```
+
+### Mặt cầu
+
+```typst
+#let S = mat-cau((1, -2, 3), 5)
+mat-cau-duong-kinh(A, B) mat-cau-tam-tiep-xuc-mp(I, P)
+mat-cau-qua-4-diem(A, B, C, D) // đồng phẳng → none
+
+#pt-mat-cau(S) // $(x−1)² + (y+2)² + (z−3)² = 25$
+#pt-mat-cau-khai-trien(S) // $x² + y² + z² − 2x + 4y − 6z − 11 = 0$
+
+vi-tri-mp-mat-cau(P, S) // "cắt nhau" | "tiếp xúc" | "không cắt"
+duong-tron-giao(P, S) // (I: tâm, R: bán kính) đường tròn giao tuyến
+```
+
+### Hiển thị — chính xác hay gần đúng
+
+```typst
+#hien-diem(A, ten: "A") // $A(1; 2; −1)$
+#hien-vecto(u, ten: "AB") // $arrow(A B) = (1; −3; 4)$
+#hien-so(v) // nguyên / phân số CHÍNH XÁC, nếu không thì thập phân
+#hien-gan-dung(v, chu-so: 2) // giá trị gần đúng
+#hien-goc(goc-2-mp(P, Q)) // 77.7°
+#hien-can(4030) // căn thức đã rút thừa số chính phương
+```
+
+Các đại lượng chứa căn có bản **chính xác** riêng (không làm tròn):
+`hien-do-dai(u)`, `hien-khoang-cach(A, B)`, `hien-cos-goc(u, v)`,
+`hien-khoang-cach-diem-mp(A, P)`, `hien-khoang-cach-diem-duong(A, d)`,
+`hien-khoang-cach-2-duong(d1, d2)`, `hien-dien-tich-tam-giac(A, B, C)`,
+`hien-dien-tich-hbh(A, B, C)`, `hien-the-tich-tu-dien(A, B, C, D)`.
+
+> `hien-so` chỉ nhận dạng số nguyên và phân số nên **luôn đúng**; muốn ra căn
+> thức thì dùng đúng hàm `hien-*` ở trên. (`so-toan` của `do-thi.typ` có đoán
+> thêm dạng căn nên với số vô tỉ bất kì có thể ra một dạng "đẹp" mà sai.)
+
+### Tính xong thì vẽ
+
+```typst
+#oxyz(w: 8cm, x: 4.4, y: 5.4, z: 4.4, them: (ctx, t3) => {
+ let Q = mat-phang-qua-phap((1, 1.2, 1.4), (1, 1, 1))
+ let (T, u, v) = khung-mp(Q, tam: (1, 1.2, 1.4), r: 2.5) // khung để vẽ
+ mat-phang-bh(ctx, t3, T, u, v, ten: $(alpha)$)
+ let H = hinh-chieu-len-mp((0, 0, 0), Q)
+ doan-oxyz(ctx, t3, (0, 0, 0), H, mau: red, dut: true)
+})
+```
+
+`khung-mp(mp, tam: auto, r: 2)` trả `(tâm, u, v)` để đưa thẳng vào
+`mat-phang-bh`; `mp-de-ve(mp)` đổi mặt phẳng sang dạng `(n: , d: )` của
+`da-dien.typ`. Mặt phẳng dạng đoạn chắn vẽ bằng `mat-phang-oxyz(ctx, t3, p, q, r)`.
+
+File thử: `thu-oxyz.typ`.
diff --git a/packages/preview/conic-toan/0.3.4/assets/exam-page.png b/packages/preview/conic-toan/0.3.4/assets/exam-page.png
new file mode 100644
index 0000000000..b350b94585
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/exam-page.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/feasible-region.png b/packages/preview/conic-toan/0.3.4/assets/feasible-region.png
new file mode 100644
index 0000000000..358dfc4121
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/feasible-region.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/graphs.png b/packages/preview/conic-toan/0.3.4/assets/graphs.png
new file mode 100644
index 0000000000..29c4e98d5b
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/graphs.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/plane-geometry.png b/packages/preview/conic-toan/0.3.4/assets/plane-geometry.png
new file mode 100644
index 0000000000..7a0711bde7
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/plane-geometry.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/slide.png b/packages/preview/conic-toan/0.3.4/assets/slide.png
new file mode 100644
index 0000000000..d33ed099d2
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/slide.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/solid-geometry.png b/packages/preview/conic-toan/0.3.4/assets/solid-geometry.png
new file mode 100644
index 0000000000..3e455b2fa4
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/solid-geometry.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/solid-of-revolution.png b/packages/preview/conic-toan/0.3.4/assets/solid-of-revolution.png
new file mode 100644
index 0000000000..131c8962f8
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/solid-of-revolution.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/statistics.png b/packages/preview/conic-toan/0.3.4/assets/statistics.png
new file mode 100644
index 0000000000..9df2479964
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/statistics.png differ
diff --git a/packages/preview/conic-toan/0.3.4/assets/variation-tables.png b/packages/preview/conic-toan/0.3.4/assets/variation-tables.png
new file mode 100644
index 0000000000..037f3c6c39
Binary files /dev/null and b/packages/preview/conic-toan/0.3.4/assets/variation-tables.png differ
diff --git a/packages/preview/conic-toan/0.3.4/baigiang.typ b/packages/preview/conic-toan/0.3.4/baigiang.typ
new file mode 100644
index 0000000000..ac8fe89fd9
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/baigiang.typ
@@ -0,0 +1,569 @@
+// =====================================================================
+// baigiang.typ — NHẬP MỘT FILE DUY NHẤT NÀY LÀ DÙNG ĐƯỢC TẤT CẢ
+// #import "baigiang.typ": *
+// =====================================================================
+#import "lib/ve.typ": *
+#import "lib/hinh-phang.typ": *
+#import "lib/hinh-khong-gian.typ": *
+#import "lib/da-dien.typ": *
+#import "lib/mat-cong.typ": *
+#import "lib/oxyz-toan.typ": *
+#import "lib/do-thi.typ": *
+#import "lib/so-do-cay.typ": *
+#import "lib/bang.typ": *
+#import "lib/khao-sat.typ": *
+#import "lib/bang-thong-ke.typ": *
+#import "lib/bieu-do-thong-ke.typ": *
+#import "lib/tron-xoay.typ": *
+#import "lib/slide.typ": *
+#import "lib/cau-hoi.typ": *
+#import "lib/che-do.typ": *
+
+// =====================================================================
+// ve-voi(ctx) — BỘ HÀM VẼ ĐÃ GẮN SẴN ctx (đỡ phải gõ ctx mỗi lệnh)
+// Khai báo MỘT dòng ở đầu khối `them:` rồi gọi hàm như bình thường,
+// bỏ tham số ctx đầu tiên:
+//
+// them: ctx => {
+// let (doan, diem, nhan, ve-ham) = ve-voi(ctx) // lấy hàm nào kê tên hàm đó
+// doan(M, N, mau: blue, dut: true)
+// ve-ham(g, mau: red)
+// diem(A, mau: red, ten: $A$)
+// }
+//
+// (Typst KHÔNG cho gọi `v.doan(M, N)` sau `let v = ve-voi(ctx)` — báo
+// "cannot directly call dictionary keys as functions"; muốn giữ một biến thì
+// phải bọc ngoặc `(v.doan)(M, N)`. Vậy nên cách kê tên ở trên là gọn nhất.)
+//
+// Cách gọi cũ `doan(ctx, M, N)` vẫn dùng được bình thường (không đổi gì).
+// LƯU Ý: các hàm KHÔNG cần ctx (trung-diem, chia, hinh-chieu, giao-ham,
+// ham-qua-2-diem, so-toan, mien-tron, giao/hop/bu...) cứ gọi thẳng như cũ;
+// đổi khung nhìn (ctx-quay, ctx-tinh-tien) thì lấy bộ hàm mới:
+// let (doan, diem) = ve-voi(ctx-quay(ctx, 30deg))
+// =====================================================================
+#let ve-voi(ctx) = (
+ // ve.typ — nguyên thuỷ
+ toa: (..a) => toa(ctx, ..a),
+ toa-pt: (..a) => toa-pt(ctx, ..a),
+ toa-nguoc: (..a) => toa-nguoc(ctx, ..a),
+ goc-truc: (..a) => goc-truc(ctx, ..a),
+ dau-mui-ten: (..a) => dau-mui-ten(ctx, ..a),
+ doan: (..a) => doan(ctx, ..a),
+ cac-doan: (..a) => cac-doan(ctx, ..a),
+ nhan: (..a) => nhan(ctx, ..a),
+ nhan-goc: (..a) => nhan-goc(ctx, ..a),
+ diem: (..a) => diem(ctx, ..a),
+ cac-diem: (..a) => cac-diem(ctx, ..a),
+ mui-ten: (..a) => mui-ten(ctx, ..a),
+ mui-ten-2-dau: (..a) => mui-ten-2-dau(ctx, ..a),
+ vecto: (..a) => vecto(ctx, ..a),
+ duong-cong: (..a) => duong-cong(ctx, ..a),
+ duong-gap-khuc: (..a) => duong-gap-khuc(ctx, ..a),
+ da-giac: (..a) => da-giac(ctx, ..a),
+ duong-tron: (..a) => duong-tron(ctx, ..a),
+ elip: (..a) => elip(ctx, ..a),
+ cung: (..a) => cung(ctx, ..a),
+ cung-elip: (..a) => cung-elip(ctx, ..a),
+ xoan-oc: (..a) => xoan-oc(ctx, ..a),
+ goc-luong-giac: (..a) => goc-luong-giac(ctx, ..a),
+ goc: (..a) => goc(ctx, ..a),
+ goc-vuong: (..a) => goc-vuong(ctx, ..a),
+ ve-goc: (..a) => ve-goc(ctx, ..a), // lối TikZ: đỉnh ở GIỮA
+ ve-goc-vuong: (..a) => ve-goc-vuong(ctx, ..a), // lối TikZ: đỉnh ở GIỮA
+ danh-dau: (..a) => danh-dau(ctx, ..a),
+ ve-ham: (..a) => ve-ham(ctx, ..a),
+ to-vung: (..a) => to-vung(ctx, ..a),
+ to-vung-2-ham: (..a) => to-vung-2-ham(ctx, ..a),
+ gach-mien: (..a) => gach-mien(ctx, ..a),
+ gach-vung: (..a) => gach-vung(ctx, ..a),
+ ve-mien: (..a) => ve-mien(ctx, ..a),
+ duong-luon: (..a) => duong-luon(ctx, ..a),
+ nhan-cong: (..a) => nhan-cong(ctx, ..a),
+ // do-thi.typ — hệ trục & gióng
+ truc: (..a) => truc(ctx, ..a),
+ luoi: (..a) => luoi(ctx, ..a),
+ vach-chia: (..a) => vach-chia(ctx, ..a),
+ he-truc: (..a) => he-truc(ctx, ..a),
+ giong: (..a) => giong(ctx, ..a),
+ tiep-tuyen: (..a) => tiep-tuyen(ctx, ..a),
+ nhan-pi: (..a) => nhan-pi(ctx, ..a),
+ // hinh-phang.typ
+ da-giac-ten: (..a) => da-giac-ten(ctx, ..a),
+ tam-giac: (..a) => tam-giac(ctx, ..a),
+ duong-cao: (..a) => duong-cao(ctx, ..a),
+ trung-tuyen: (..a) => trung-tuyen(ctx, ..a),
+ phan-giac: (..a) => phan-giac(ctx, ..a),
+ trung-truc: (..a) => trung-truc(ctx, ..a),
+ tam-giac-deu: (..a) => tam-giac-deu(ctx, ..a),
+ tam-giac-vuong: (..a) => tam-giac-vuong(ctx, ..a),
+ tam-giac-can: (..a) => tam-giac-can(ctx, ..a),
+ tam-giac-vuong-can: (..a) => tam-giac-vuong-can(ctx, ..a),
+ tu-giac: (..a) => tu-giac(ctx, ..a),
+ hinh-binh-hanh: (..a) => hinh-binh-hanh(ctx, ..a),
+ hinh-chu-nhat: (..a) => hinh-chu-nhat(ctx, ..a),
+ hinh-thang: (..a) => hinh-thang(ctx, ..a),
+ duong-tron-ngoai-tiep: (..a) => duong-tron-ngoai-tiep(ctx, ..a),
+ duong-tron-noi-tiep: (..a) => duong-tron-noi-tiep(ctx, ..a),
+ duong-tron-bang-tiep: (..a) => duong-tron-bang-tiep(ctx, ..a),
+ ve-truc-tam: (..a) => ve-truc-tam(ctx, ..a),
+ tiep-tuyen-tu-diem: (..a) => tiep-tuyen-tu-diem(ctx, ..a),
+ tiep-tuyen-tai-diem: (..a) => tiep-tuyen-tai-diem(ctx, ..a),
+ // tron-xoay.typ
+ ve-khoi-xoay: (..a) => ve-khoi-xoay(ctx, ..a),
+ ve-mien-xoay: (..a) => ve-mien-xoay(ctx, ..a),
+ // hinh-khong-gian.typ — Oxyz
+ diem-oxyz: (..a) => diem-oxyz(ctx, ..a),
+ doan-oxyz: (..a) => doan-oxyz(ctx, ..a),
+ vecto-oxyz: (..a) => vecto-oxyz(ctx, ..a),
+ giong-oxyz: (..a) => giong-oxyz(ctx, ..a),
+ // da-dien.typ — engine đa diện tổng quát, mặt phẳng, thiết diện
+ // (da-dien / da-dien-thiet-dien TỰ tạo khung nên không kê ở đây)
+ ve-da-dien: (..a) => ve-da-dien(ctx, ..a),
+ mat-phang: (..a) => mat-phang(ctx, ..a),
+ mat-phang-oxyz: (..a) => mat-phang-oxyz(ctx, ..a),
+ mat-phang-bh: (..a) => mat-phang-bh(ctx, ..a),
+ ve-thiet-dien: (..a) => ve-thiet-dien(ctx, ..a),
+ // mat-cong.typ — nón/trụ có nét khuất tự động (mat-cong tự tạo khung, không kê)
+ ve-mat-cong: (..a) => ve-mat-cong(ctx, ..a),
+ ve-truc-3d: (..a) => ve-truc-3d(ctx, ..a),
+)
+
+
+// =====================================================================
+// ctx NGẦM — gọi hàm vẽ KHÔNG cần truyền ctx (07/2026)
+// Trong thân #hinh(...) hoặc trong `them: ctx => ...` của mọi hình/đồ thị
+// dựng sẵn, cứ gọi thẳng:
+//
+// them: ctx => {
+// doan(M, N, mau: blue, dut: true)
+// ve-ham(g, mau: red)
+// diem(A, mau: red, ten: $A$)
+// }
+//
+// Cách CŨ `doan(ctx, M, N)` vẫn chạy y nguyên (đối số đầu là ctx thì gọi
+// thẳng) — bài soạn cũ không phải sửa gì.
+// NGOẠI LỆ vẫn phải truyền ctx: các hàm TRẢ VỀ GIÁ TRỊ, không vẽ —
+// toa, toa-pt, toa-nguoc, goc-truc, ctx-quay, ctx-tinh-tien.
+// (Bên trong lib các hàm vẫn gọi nhau theo lối cũ nên không ảnh hưởng.)
+// =====================================================================
+// ve.typ — nguyên thuỷ vẽ
+/// `doan(A, B, mau: black, day: 1pt, dut: false, ten: none, tai: 0.5, huong: auto, cach: 6pt, ten-quay: false, mau-ten: auto)`
+///
+/// Đoạn thẳng AB, kèm NHÃN CHÚ THÍCH đặt trên đoạn (tuỳ chọn):
+/// ten : nội dung nhãn (vd $a$, [3 cm]) — none = không ghi
+/// tai : vị trí nhãn theo TỈ LỆ từ A đến B (0 = tại A, 1 = tại B, .5 = giữa)
+/// huong : auto = tự đặt VUÔNG GÓC phía trên đoạn (đoạn dọc thì ra trái);
+/// hoặc tên hướng ("above", "below", "left", ...), hoặc vectơ (dx, dy)
+/// cach : khoảng cách từ đoạn tới nhãn
+#let doan = _voi-ctx(doan)
+/// `cac-doan(..noi-dung, mau: black, day: 1pt, dut: false, dong: false, to: none, mui-ten: false, ten: none, tai: 0.5, huong: auto, cach: 6pt, ten-quay: false, mau-ten: auto)`
+///
+/// Vẽ NHIỀU nét trong một lệnh: điểm liền nhau thành một đường gấp khúc,
+/// mỗi MẢNG điểm là một nét riêng — `cac-doan((A, B), (C, D), (M, N))`.
+/// Nét cần kiểu riêng thì bọc `duong(...)`.
+#let cac-doan = _voi-ctx(cac-doan)
+/// `nhan(P, noi-dung, huong: "above", cach: 6pt, mau: black, quay: 0deg)`
+///
+/// Nhãn văn bản đặt cạnh điểm P.
+/// huong: "above", "below", "left", "right", "above-left", "above-right",
+/// "below-left", "below-right", "center"
+/// (tên tiếng Việt cũ "tren", "duoi", "trai", "phai", ... vẫn dùng được)
+/// quay: góc xoay chữ (vd 90deg, -45deg, hoặc goc-truc(...)). Mặc định 0deg.
+/// Đặt chữ/công thức tại điểm P. `huong` là phía đặt chữ so với P
+#let nhan = _voi-ctx(nhan)
+/// `nhan-goc(..muc, mau: black, ban-kinh: 6pt)`
+///
+/// Đặt nhãn cho nhiều điểm, VỊ TRÍ nhãn xác định bằng GÓC LƯỢNG GIÁC (thay cho
+/// tên hướng "above"/"below"…). Mỗi mục là một tuple:
+/// (P, nội-dung, goc) — bán kính & màu lấy mặc định
+/// (P, nội-dung, goc, ban-kinh) — ban-kinh là ĐỘ DÀI trang (vd 8pt)
+/// (P, nội-dung, goc, ban-kinh, mau) — kèm màu riêng
+/// `goc` là góc lượng giác (số trần = ĐỘ, dương = ngược kim đồng hồ; nhãn đặt
+#let nhan-goc = _voi-ctx(nhan-goc)
+/// `diem(P, ten: none, huong: "tren", bk: 2pt, mau: black, cach: 6pt, mau-ten: black)`
+///
+/// Điểm (chấm tròn) + nhãn tuỳ chọn.
+/// cach: khoảng cách nhãn tới điểm · mau-ten: màu chữ (auto = màu chấm).
+/// Chấm một điểm, kèm tên: `diem(A, ten: $A$, huong: "below-left")`.
+/// Nhiều điểm một lệnh thì dùng `cac-diem`.
+#let diem = _voi-ctx(diem)
+/// `cac-diem(..noi-dung, mau: black, bk: 2pt, huong: "above", cach: 6pt, mau-ten: black)`
+///
+/// Mỗi đối số vị trí là một điểm, viết theo 1 trong 4 dạng:
+/// A -> chỉ chấm, không nhãn
+/// (A, $A$) -> chấm + nhãn, hướng lấy theo `huong` chung
+/// (A, $A$, "left") -> + hướng riêng
+/// (A, $A$, "left", red) -> + màu riêng (cả chấm lẫn chữ nếu mau-ten: auto)
+/// Tuỳ chọn chung: mau · bk (bán kính chấm) · huong · cach (nhãn cách chấm)
+#let cac-diem = _voi-ctx(cac-diem)
+/// `mui-ten(A, B, mau: black, day: 1pt, kich: 7pt, dut: false)`
+///
+/// Mũi tên A -> B.
+#let mui-ten = _voi-ctx(mui-ten)
+/// `mui-ten-2-dau(A, B, mau: black, day: 1pt, kich: 7pt, dut: false, ten: none, huong: auto, cach: 5pt, ten-quay: false, mau-ten: auto, trong: auto, dem: 3pt, nen: white, vach: false, dai-vach: 9pt, le: 0pt)`
+///
+/// không đủ dài thì tự đặt chữ ra ngoài (phía trên đoạn)
+/// huong : chỗ đặt chữ khi KHÔNG nằm giữa thân
+/// (auto = vuông góc phía trên đoạn, như `doan`)
+/// ten-quay : true = chữ NẰM DỌC theo mũi tên (luôn đọc xuôi)
+/// vach : true = kẻ vạch chặn vuông góc ở hai đầu (kiểu ghi kích thước)
+/// le : lùi hai đầu mũi tên vào trong, để hở khỏi vật đang đo
+#let mui-ten-2-dau = _voi-ctx(mui-ten-2-dau)
+/// `vecto(A, B, ten: none, huong: "tren", mau: black, day: 1.1pt, dut: false)`
+///
+/// Vectơ A -> B kèm tên đặt ở trung điểm.
+/// dut: true = thân nét đứt (vectơ nằm trên cạnh khuất, kiểu hình SGK);
+/// đầu mũi tên luôn nét liền.
+#let vecto = _voi-ctx(vecto)
+/// `duong-cong(cac-diem, mau: black, day: 1pt, dut: false, dong: false)`
+///
+/// Đường gấp khúc qua dãy điểm. dut: nét đứt (vẽ cách đoạn -> đều trên đường cong).
+#let duong-cong = _voi-ctx(duong-cong)
+/// `duong-gap-khuc(cac-diem, mau: black, day: 1pt, dut: false, dong: false)`
+///
+/// Đường gấp khúc nối lần lượt A - B - C - ... KHÁC duong-cong ở chỗ:
+/// dut: true là nét đứt THẬT trên TỪNG đoạn (hợp polyline ít đoạn;
+/// duong-cong đứt kiểu "bỏ đoạn xen kẽ" chỉ hợp đường nhiều mẫu).
+/// dong: true nối điểm cuối về điểm đầu.
+#let duong-gap-khuc = _voi-ctx(duong-gap-khuc)
+/// `da-giac(cac-diem, mau: black, day: 1pt, dut: false, to: none)`
+///
+/// Đa giác kín: viền + tô màu tuỳ chọn (to: màu hoặc none).
+/// Đa giác khép kín. Đỉnh truyền vào dưới dạng MỘT MẢNG:
+/// `da-giac((A, B, C))` — không phải `da-giac(A, B, C)`. `to:` để tô màu.
+#let da-giac = _voi-ctx(da-giac)
+/// `duong-tron(O, r, mau: black, day: 1pt, dut: false, to: none)`
+///
+/// Đường tròn tâm O bán kính r (theo đơn vị trục x; nếu 2 trục cùng tỉ lệ
+/// thì là đường tròn thật, khác tỉ lệ sẽ thành elip tương ứng).
+#let duong-tron = _voi-ctx(duong-tron)
+/// `elip(O, a, b, mau: black, day: 1pt, dut: false, to: none, quay: 0deg, n: 72)`
+///
+/// Elip tâm O, bán trục a (ngang), b (dọc); quay: góc xoay quanh tâm
+/// (dương = ngược chiều kim đồng hồ), n: số mẫu khi vẽ elip xoay.
+#let elip = _voi-ctx(elip)
+/// `cung(O, r, tu: 0deg, den: 180deg, mau: black, day: 1pt, dut: false, n: 48, quay: 0deg)`
+///
+/// Cung tròn tâm O bán kính r từ góc `tu` đến `den` (kiểu angle, vd 30deg).
+#let cung = _voi-ctx(cung)
+/// `cung-elip(O, a, b, tu: 0deg, den: 180deg, mau: black, day: 1pt, dut: false, n: 48, quay: 0deg)`
+///
+/// Cung elip (dùng cho hình không gian: đáy nón, trụ...).
+#let cung-elip = _voi-ctx(cung-elip)
+/// `xoan-oc(O, tu: 0deg, den: 360deg, r: 0.12, r-cuoi: auto, buoc: 0.16, mau: black, day: 1pt, dut: false, mui-ten: true, kich: 7pt, n: auto, ten: none, huong: "above", cach: 6pt, mau-ten: auto)`
+///
+/// Xoắn ốc Archimedes tâm O: bán kính TĂNG ĐỀU theo góc, quét từ `tu` đến `den`.
+/// Góc quét |den - tu| ĐƯỢC PHÉP lớn hơn 360° (nhiều vòng);
+/// den > tu = ngược chiều kim đồng hồ (chiều dương), den < tu = cùng chiều kim đồng hồ.
+/// r : bán kính tại góc `tu`
+/// r-cuoi : bán kính tại góc `den` (auto = r + buoc · số vòng quét)
+/// buoc : khoảng cách giữa hai vòng liên tiếp (dùng khi r-cuoi: auto)
+#let xoan-oc = _voi-ctx(xoan-oc)
+/// `goc-luong-giac(O, A, M, chieu: "duong", vong: 0, r: auto, r-cuoi: auto, buoc: auto, mau: blue, day: 0.9pt, dut: false, kich: 6pt, ten: none, so-do: false, huong: "above", cach: 6pt, mau-ten: auto, n: auto)`
+///
+/// Góc lượng giác (OA, OM): xoắn ốc từ tia OA quay tới tia OM (kiểu SGK 11).
+/// chieu : "duong" = ngược chiều kim đồng hồ (mặc định) · "am" = cùng chiều
+/// vong : số vòng quay THÊM (0, 1, 2, ...) trước khi dừng ở tia OM
+/// so-do : true = tự ghi số đo (vd -430°) cạnh mũi tên; `ten` được ưu tiên
+/// Ví dụ hình "AOM = 70°, (OA, OM) = -430°": goc-luong-giac(O, A, M, chieu: "am", vong: 1)
+#let goc-luong-giac = _voi-ctx(goc-luong-giac)
+/// `goc(O, A, B, r: 0.45, ten: none, so-do: false, so-cung: 1, vach: 0, vach-danh-dau: auto, dai-vach: 6pt, to: none, mau: black, day: 0.8pt, cach-nhan: 1.9)`
+///
+/// Góc (thường, không vuông) giữa hai tia O->A và O->B:
+/// cung nhỏ + nhãn + số cung (1..3) + tô quạt + tự ghi số đo.
+/// to : màu tô hình quạt (nên trong suốt, vd rgb(255, 170, 0, 70))
+/// so-do : true = tự ghi số đo góc (vd 60°) khi không đặt `ten`
+/// vach : 1..3 vạch CẮT NGANG cung (ký hiệu hai góc bằng nhau, kiểu SGK).
+/// Khác `so-cung` (vẽ nhiều cung đồng tâm) — `vach` chỉ vẽ MỘT cung
+#let goc = _voi-ctx(goc)
+/// `goc-vuong(O, A, B, r: 0.32, mau: black, day: 0.8pt)`
+///
+/// Ký hiệu góc vuông tại O (giữa hai tia O->A, O->B).
+/// Ký hiệu góc vuông TẠI O (giữa hai tia O→A, O→B). ĐỈNH là đối số ĐẦU.
+/// Quen lối TikZ (đỉnh ở giữa) thì dùng `ve-goc-vuong(A, O, B)`.
+#let goc-vuong = _voi-ctx(goc-vuong)
+/// `ve-goc(A, O, B, ..tuy-chon)`
+///
+/// `goc`/`goc-vuong` ở trên đặt ĐỈNH góc làm đối số ĐẦU: goc-vuong(O, A, B).
+/// TikZ lại viết đỉnh ở GIỮA (pic angle = A--O--B), nên hai hàm dưới đây nhận
+/// thứ tự quen thuộc đó — đỉnh là đối số THỨ HAI:
+/// ve-goc(A, O, B, ...) <=> goc(O, A, B, ...)
+/// ve-goc-vuong(A, O, B, ...) <=> goc-vuong(O, A, B, ...)
+/// Mọi tuỳ chọn (r, ten, so-do, so-cung, vach, to, mau, day, cach-nhan…) giữ
+#let ve-goc = _voi-ctx(ve-goc)
+/// `ve-goc-vuong(A, O, B, ..tuy-chon)`
+///
+/// Ký hiệu góc vuông TẠI O theo lối TikZ: ĐỈNH nằm GIỮA — `ve-goc-vuong(A, O, B)`.
+/// Cùng bộ tuỳ chọn với `goc-vuong` (r, mau, day).
+#let ve-goc-vuong = _voi-ctx(ve-goc-vuong)
+/// `danh-dau(A, B, so: 1, dai: 6pt, mau: black, day: 1pt, nghieng: 0deg, cheo: false)`
+///
+/// so : số vạch song song (1/2/3…) tại trung điểm đoạn.
+/// nghieng: góc NGHIÊNG của vạch so với pháp tuyến đoạn (0deg = vuông góc như cũ);
+/// đặt ~20–30deg cho kiểu vạch chéo "/" "//" "///".
+/// cheo : true = mỗi mốc vẽ dấu CHÉO NHAU "✕" (hai vạch ±góc); khi đó `nghieng`
+/// = nửa góc mở của dấu ✕ (mặc định 30deg nếu để 0deg).
+#let danh-dau = _voi-ctx(danh-dau)
+/// `ve-ham(f, tu: auto, den: auto, n: 150, mau: blue, day: 1.3pt, dut: false)`
+///
+/// Vẽ đồ thị hàm f trên [tu, den]; tự tách nhánh khi ra ngoài cửa sổ y
+/// (dùng được cho hàm có tiệm cận đứng như 1/x, tan x).
+#let ve-ham = _voi-ctx(ve-ham)
+/// `to-vung(f, a, b, mau: rgb(30, 100, 200, 60), n: 80)`
+///
+/// Tô miền giữa đồ thị f và trục hoành trên [a, b] (mau nên có độ trong suốt).
+#let to-vung = _voi-ctx(to-vung)
+/// `to-vung-2-ham(f, g, a, b, mau: rgb(30, 100, 200, 60), n: 80)`
+///
+/// Tô miền giữa HAI đồ thị f và g trên [a, b] (mau nên có độ trong suốt).
+#let to-vung-2-ham = _voi-ctx(to-vung-2-ham)
+/// `gach-mien(cac-dinh, goc: 45deg, buoc: 6.5pt, mau: red, day: 0.55pt)`
+///
+/// Gạch chéo đa giác lồi theo toạ độ TOÁN.
+#let gach-mien = _voi-ctx(gach-mien)
+/// `gach-vung(kiem, goc: 45deg, buoc: 6.5pt, mau: red, day: 0.55pt, n: 180)`
+///
+/// Gạch chéo MIỀN BẤT KÌ (biên cong tuỳ ý).
+/// kiem: miền đặt tên/tổ hợp giao-hop-bu (khuyên dùng) HOẶC hàm P => true/false:
+/// gach-vung(ctx, giao(A, B, bu(C)))
+/// gach-vung(ctx, P => trong(P, A) and P.at(1) > 0)
+/// goc: hướng vạch; buoc: khoảng cách 2 vạch; n: số mẫu mỗi vạch (biên càng mịn).
+#let gach-vung = _voi-ctx(gach-vung)
+/// `ve-mien(m, mau: black, day: 1pt, dut: false, to: none)`
+///
+/// Vẽ biên miền (đường tròn/elip tương ứng với khai báo).
+#let ve-mien = _voi-ctx(ve-mien)
+/// `duong-luon(..noi-dung, mau: black, day: 1pt, dut: false, dong: false, to: none, mui-ten: false, kich: 7pt, ten: none, tai: 0.5, huong: auto, cach: 6pt, ten-quay: false, mau-ten: auto, n: 16)`
+///
+/// Vẽ đường cong uốn lượn qua các điểm neo, điểm điều khiển kiểu `controls` TikZ.
+/// duong-luon(A, B, C, D) // trơn tự động qua 4 neo
+/// duong-luon(A, dieu-khien(c1, c2), B) // Bézier bậc ba một đoạn
+/// duong-luon(A, dieu-khien(c1,c2), B, C, dong: true, to: blue.lighten(85%))
+/// Tuỳ chọn: mau · day · dut · dong (khép kín) · to (tô) · mui-ten · kich ·
+/// ten/tai/huong/cach/ten-quay/mau-ten (nhãn đặt theo TỈ LỆ độ dài, như cac-doan) ·
+#let duong-luon = _voi-ctx(duong-luon)
+/// `nhan-cong(duong, chu, tu: 0, can: "trai", khoang: 0pt, co: auto, mau: black, phia: "tren", cach: 2pt, dao: false)`
+///
+/// Đặt từng ký tự của `chu` DỌC THEO đường `duong` (mảng điểm toạ độ toán, vd
+/// lấy từ diem-luon / diem-cung / lay-mau), tự xoay tiếp tuyến, canh theo độ dài cung.
+/// duong: mảng điểm (>= 2). chu: str (khuyên dùng) hoặc content văn bản thuần.
+/// tu : vị trí BẮT ĐẦU theo tỉ lệ độ dài đường (0 = đầu, 1 = cuối, .5 = giữa)
+/// can : "trai" (bắt đầu tại `tu`) · "giua" (canh giữa chữ quanh `tu`) · "phai"
+/// khoang: giãn cách thêm giữa các ký tự (length)
+#let nhan-cong = _voi-ctx(nhan-cong)
+/// `dau-mui-ten(A, B, mau: black, kich: 7pt)`
+///
+/// Đầu mũi tên tại B, hướng A->B.
+#let dau-mui-ten = _voi-ctx(dau-mui-ten)
+
+// do-thi.typ — hệ trục, lưới, vạch chia, đường gióng
+/// `truc(ten-x: $x$, ten-y: $y$, ten-goc: $O$, mau: black, day: 0.9pt)`
+///
+#let truc = _voi-ctx(truc)
+/// `luoi(buoc: 1, mau: luma(87%), day: 0.5pt)`
+///
+/// Lưới ô vuông mờ.
+#let luoi = _voi-ctx(luoi)
+/// `vach-chia(buoc-x: 1, buoc-y: 1, so: true, mau: black)`
+///
+/// Vạch chia + số trên hai trục (bỏ qua 0).
+#let vach-chia = _voi-ctx(vach-chia)
+/// `he-truc(ten-x: $x$, ten-y: $y$, ten-goc: $O$, luoi-o: true, buoc-luoi: auto, vach: true, so: true, buoc-x: 1, buoc-y: 1, mau: black, day: 0.9pt, mau-luoi: luma(87%))`
+///
+/// Hệ trục TRỌN GÓI: lưới + trục + vạch chia + số — một lệnh thay ba.
+/// #hinh(xmin: -4, xmax: 4, ymin: -3, ymax: 3, ctx => { he-truc(ctx) ... })
+/// luoi-o: lưới ô vuông mờ · vach: vạch chia đơn vị · so: ghi số trên vạch
+#let he-truc = _voi-ctx(he-truc)
+/// `giong(P, ten-x: auto, ten-y: auto, huong-x: "below", huong-y: "left", mau: gray.darken(20%), mau-diem: red, diem-to: true)`
+///
+/// Đường gióng từ điểm P về 2 trục (nét đứt) + nhãn toạ độ trên trục.
+/// ten-x / ten-y: auto = tự ghi số; none = không ghi; hoặc nội dung tuỳ ý.
+#let giong = _voi-ctx(giong)
+/// `tiep-tuyen(f, x0, mau: red, day: 1.2pt, dut: false, dai: auto, cham: true, mau-cham: auto, ten-diem: none, huong-diem: "above-left", ten: none, tai: 0.9, huong-ten: auto, cach: 6pt, ten-quay: false, giong: false, h: auto)`
+///
+/// TIẾP TUYẾN của đồ thị y = f(x) tại điểm có hoành độ x0 (hoặc DÃY hoành độ).
+/// Hệ số góc lấy bằng đạo hàm số nên dùng được cho MỌI hàm f viết bằng closure.
+/// tiep-tuyen(f, 1) // tiếp tuyến tại x = 1
+/// tiep-tuyen(f, (-1, 2), mau: green) // nhiều tiếp điểm
+/// tiep-tuyen(f, 1, ten: auto) // tự ghi y = kx + m
+/// tiep-tuyen(f, 1, ten: $Delta$, ten-diem: $M$) // đặt tên tuỳ ý
+#let tiep-tuyen = _voi-ctx(tiep-tuyen)
+/// `nhan-pi()`
+///
+/// Ghi nhãn các bội của π trên trục hoành.
+#let nhan-pi = _voi-ctx(nhan-pi)
+
+// hinh-phang.typ — tam giác, tứ giác, đường tròn đặc biệt
+/// `da-giac-ten(dinh, ten: none, mau: black, day: 1.1pt, to: none, cham: true)`
+///
+/// Vẽ đa giác + nhãn đỉnh tự động hướng ra ngoài.
+/// ten: mảng nội dung nhãn (cùng độ dài số đỉnh) hoặc none.
+#let da-giac-ten = _voi-ctx(da-giac-ten)
+/// `tam-giac(A, B, C, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, cham: true)`
+///
+#let tam-giac = _voi-ctx(tam-giac)
+/// `duong-cao(P, A, B, ten-chan: none, mau: red, day: 1pt, dut: false, vuong: true)`
+///
+/// Đường cao hạ từ P xuống đường thẳng AB (kèm ký hiệu vuông góc).
+/// ten-chan: nhãn chân đường cao (vd $H$).
+#let duong-cao = _voi-ctx(duong-cao)
+/// `trung-tuyen(P, A, B, ten-chan: none, mau: blue, day: 1pt, dut: false, so-vach: 1)`
+///
+/// Trung tuyến từ P đến trung điểm AB (kèm 2 vạch đánh dấu bằng nhau).
+#let trung-tuyen = _voi-ctx(trung-tuyen)
+/// `phan-giac(O, A, B, ten-chan: none, mau: green.darken(20%), day: 1pt, dut: false, r-cung: 0.5, vach: 0, so-cung: 1, dai-vach: 6pt, lech: auto, ten-goc: none, cach-nhan: 1.9)`
+///
+/// Phân giác trong góc O của tam giác OAB (kèm 2 cung đánh dấu góc bằng nhau).
+/// vach : số VẠCH gạch ngang cung (0/1/2/3) — ký hiệu hai góc bằng nhau
+/// so-cung : số CUNG đồng tâm vẽ ở mỗi góc (1/2/3)
+/// lech : hệ số bán kính cung thứ hai so với cung thứ nhất
+/// (auto = 1.18 khi KHÔNG đánh dấu, = 1 khi có vạch/nhiều cung —
+/// vì hai góc bằng nhau thì phải vẽ cùng bán kính mới đúng quy ước)
+#let phan-giac = _voi-ctx(phan-giac)
+/// `trung-truc(A, B, dai: 1.5, mau: purple, day: 1pt, dut: true, so-vach: 1)`
+///
+/// Đường trung trực của đoạn AB (đoạn vuông góc tại trung điểm, dài 2*dai).
+#let trung-truc = _voi-ctx(trung-truc)
+/// `tam-giac-deu(A, canh, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, vach: 1)`
+///
+/// Tam giác đều cạnh `canh`, đỉnh A ở dưới-trái, đáy nằm ngang.
+/// vach: số vạch đánh dấu cạnh bằng nhau (0 = không vẽ).
+#let tam-giac-deu = _voi-ctx(tam-giac-deu)
+/// `tam-giac-vuong(A, a, b, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none)`
+///
+/// Tam giác vuông tại A: cạnh AB ngang dài a, cạnh AC đứng dài b.
+#let tam-giac-vuong = _voi-ctx(tam-giac-vuong)
+/// `tam-giac-can(A, canh-day, chieu-cao, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, vach: 1)`
+///
+/// Tam giác cân tại C: đáy AB ngang dài `canh-day`, chiều cao `chieu-cao`.
+#let tam-giac-can = _voi-ctx(tam-giac-can)
+/// `tam-giac-vuong-can(A, canh, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, vach: 1)`
+///
+/// Tam giác vuông cân tại A, hai cạnh góc vuông dài `canh`.
+#let tam-giac-vuong-can = _voi-ctx(tam-giac-vuong-can)
+/// `tu-giac(A, B, C, D, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none)`
+///
+/// Tứ giác bất kỳ ABCD.
+#let tu-giac = _voi-ctx(tu-giac)
+/// `hinh-binh-hanh(A, B, C, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none)`
+///
+/// Hình bình hành biết 3 đỉnh liên tiếp A, B, C (D tự tính = A + C - B).
+#let hinh-binh-hanh = _voi-ctx(hinh-binh-hanh)
+/// `hinh-chu-nhat(A, r, c, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none, goc-vg: true)`
+///
+/// Hình chữ nhật: góc dưới-trái A, chiều rộng r, chiều cao c.
+#let hinh-chu-nhat = _voi-ctx(hinh-chu-nhat)
+/// `hinh-thang(A, a, b, c, lech: 0.6, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none)`
+///
+/// Hình thang: đáy lớn AB (dưới, dài a), đáy nhỏ DC (trên, dài b),
+/// cao c, lech: độ lệch ngang của D so với A.
+#let hinh-thang = _voi-ctx(hinh-thang)
+/// `duong-tron-ngoai-tiep(..vi-tri, ten-tam: $O$, huong-tam: auto, mau: blue, day: 1pt, dut: false, to: none, ban-kinh: false, dinh-r: auto, ten-r: none, canh: false, mau-canh: black)`
+///
+/// Đường tròn ngoại tiếp TAM GIÁC hoặc ĐA GIÁC.
+/// duong-tron-ngoai-tiep(A, B, C) — 3 đỉnh rời (lối cũ)
+/// duong-tron-ngoai-tiep((A, B, C, D, E)) — MỘT MẢNG đỉnh, đa giác tuỳ ý
+/// Từ 4 đỉnh trở lên, tâm/bán kính khớp theo bình phương bé nhất (đa giác nội
+/// tiếp được cho ra đúng đường tròn của nó — xem `tron-qua-diem`).
+/// ten-tam : nhãn tâm (none = không ghi)
+#let duong-tron-ngoai-tiep = _voi-ctx(duong-tron-ngoai-tiep)
+/// `duong-tron-noi-tiep(A, B, C, ten-tam: $I$, mau: orange.darken(10%), day: 1pt, dut: false, ban-kinh: false)`
+///
+/// Đường tròn nội tiếp tam giác ABC.
+#let duong-tron-noi-tiep = _voi-ctx(duong-tron-noi-tiep)
+/// `duong-tron-bang-tiep(A, B, C, ten-tam: $J$, ten-tiep: none, mau: green.darken(25%), day: 1pt, keo-dai: true, tiep-diem: true, ban-kinh: false)`
+///
+/// Đường tròn bàng tiếp TRONG GÓC A của tam giác ABC (tiếp xúc cạnh BC và
+/// phần kéo dài của AB, AC). Muốn góc B thì gọi duong-tron-bang-tiep(ctx, B, C, A).
+/// ten-tam : nhãn tâm (mặc định $J$)
+/// keo-dai : vẽ nét đứt kéo dài hai cạnh AB, AC tới tiếp điểm
+/// tiep-diem : chấm 3 tiếp điểm
+/// ban-kinh : vẽ bán kính tới cạnh BC (kèm ký hiệu vuông góc)
+#let duong-tron-bang-tiep = _voi-ctx(duong-tron-bang-tiep)
+/// `ve-truc-tam(A, B, C, ten: $H$, ten-chan: ($H_A$, $H_B$, $H_C$), ten-dinh: ($A$, $B$, $C$), canh: true, mau: black, mau-cao: red, day: 1.1pt, dut: false, vuong: true)`
+///
+/// Ba đường cao của tam giác ABC + trực tâm H.
+/// ten : nhãn trực tâm (none = không ghi)
+/// ten-chan : nhãn 3 chân đường cao theo thứ tự hạ từ A, B, C
+/// (none = không ghi; mặc định $H_A$, $H_B$, $H_C$)
+/// vuong : vẽ ký hiệu góc vuông tại chân đường cao
+/// canh : true = vẽ luôn tam giác ABC (kèm nhãn đỉnh)
+#let ve-truc-tam = _voi-ctx(ve-truc-tam)
+/// `tiep-tuyen-tu-diem(O, r, M, ten-tam: $O$, ten-diem: $M$, ten-tiep: ($T_1$, $T_2$), mau: black, mau-tt: red, day: 1pt)`
+///
+/// Hai tiếp tuyến kẻ từ điểm M ngoài đường tròn (O; r).
+#let tiep-tuyen-tu-diem = _voi-ctx(tiep-tuyen-tu-diem)
+/// `tiep-tuyen-tai-diem(O, r, M, dai: auto, mau: black, day: 1pt, dut: false)`
+///
+/// Dựng tiếp tuyến của đường tròn (O; r) TẠI điểm M (M nằm trên đường tròn):
+/// đoạn thẳng qua M, VUÔNG GÓC với bán kính OM.
+/// dai : nửa độ dài đoạn tiếp tuyến (auto = 1.4·r); đơn vị toạ độ toán.
+/// mau / day / dut : kiểu nét của đoạn tiếp tuyến.
+/// M không cần chính xác trên đường tròn — hướng tiếp tuyến lấy theo OM thực tế.
+/// Tiếp tuyến của (O; r) TẠI điểm M: đoạn qua M vuông góc OM.
+#let tiep-tuyen-tai-diem = _voi-ctx(tiep-tuyen-tai-diem)
+
+// tron-xoay.typ — khối tròn xoay (khoi-tron-xoay tự tạo khung, không kê ở đây)
+/// `ve-khoi-xoay(f, a, b, g: none, ngang: true, k: 0.26, mau: blue, day: 1.2pt, mau-to: rgb(70, 130, 200, 60), duong-sinh: true, truc-mo: true, mat-cat: none, mau-mat-cat: rgb(235, 130, 40, 120), ten-ban-kinh: auto, ten-mat-cat: auto, n: 120)`
+///
+#let ve-khoi-xoay = _voi-ctx(ve-khoi-xoay)
+/// `ve-mien-xoay(f, a, b, g: none, ngang: true, mau: blue, day: 1.3pt, mau-to: rgb(70, 130, 200, 90), ten-ham: auto, ten-ham-trong: none, ten-a: auto, ten-b: auto, giong-ab: true, n: 120)`
+///
+#let ve-mien-xoay = _voi-ctx(ve-mien-xoay)
+
+// hinh-khong-gian.typ — nhóm Oxyz
+/// `diem-oxyz(t3, P, ten: none, huong: "above", bk: 2pt, mau: black)`
+///
+/// Điểm 3D: chấm + nhãn.
+#let diem-oxyz = _voi-ctx(diem-oxyz)
+/// `doan-oxyz(t3, A, B, mau: black, day: 1pt, dut: false)`
+///
+/// Đoạn / vectơ nối hai điểm 3D.
+#let doan-oxyz = _voi-ctx(doan-oxyz)
+/// `vecto-oxyz(t3, A, B, ten: none, huong: "tren", mau: black, day: 1.1pt, dut: false)`
+///
+#let vecto-oxyz = _voi-ctx(vecto-oxyz)
+/// `giong-oxyz(t3, P, mau: blue, day: 0.8pt)`
+///
+/// Hộp gióng nét đứt từ gốc O đến điểm P = (a, b, c): vẽ các cạnh của
+/// hình hộp chữ nhật [0,a]×[0,b]×[0,c] (trừ 3 cạnh nằm trên trục).
+#let giong-oxyz = _voi-ctx(giong-oxyz)
+
+// da-dien.typ — khối đa diện tổng quát, mặt phẳng, thiết diện
+// (da-dien, da-dien-thiet-dien tự tạo khung; các hàm TRẢ GIÁ TRỊ như
+// chieu-*, khoi-*, mp-*, thiet-dien, v3-*, phan-tich-khoi KHÔNG bọc)
+/// `ve-da-dien(dinh: (), mat: (), cam: auto, ten: none, huong: auto, cach: 6pt, mau-ten: black, hien-dinh: true, bk: 1.6pt, mau: black, day: 1.1pt, mau-khuat: auto, day-khuat: auto, hien-khuat: true, to: none, to-mat: none, diem: (), duong: (), mau-diem: auto, bk-diem: 1.8pt, them: none)`
+///
+#let ve-da-dien = _voi-ctx(ve-da-dien)
+/// `mat-phang(p, dinh3, to: auto, mau: blue.darken(15%), day: 1pt, dut: false, ten: none, ten-tai: 0, huong: "above-left", cach: 5pt, che: (), mau-che: black, day-che: 0.9pt, nhin: auto, them: none)`
+///
+#let mat-phang = _voi-ctx(mat-phang)
+/// `mat-phang-oxyz(t3, a, b, c, to: auto, mau: blue.darken(15%), day: 1pt, dut: false, ten: none, ten-tai: 0, huong: "above-left", cach: 5pt, ten-dinh: false, ten-abc: ($A$, $B$, $C$), hien-dinh: true, bk: 1.8pt, truc: auto, mau-che: black, day-che: 0.9pt, them: none)`
+///
+#let mat-phang-oxyz = _voi-ctx(mat-phang-oxyz)
+/// `mat-phang-bh(t3, tam, u, v, to: auto, mau: blue.darken(15%), day: 1pt, dut: false, ten: none, ten-tai: 1, huong: "above-right", cach: 5pt, truc: none, mau-che: black, day-che: 0.9pt, them: none)`
+///
+#let mat-phang-bh = _voi-ctx(mat-phang-bh)
+/// `ve-thiet-dien(dinh: (), mat: (), mp: none, cam: auto, to: auto, mau: red.darken(10%), day: 1.1pt, hien-khuat: true, mau-khuat: auto, day-khuat: auto, ten: none, huong: auto, cach: 6pt, hien-dinh: true, bk: 1.8pt, mau-ten: auto, them: none)`
+///
+/// Vẽ thiết diện TRÊN một khối: tô miền + viền LIỀN ở phần nằm trên mặt
+/// THẤY, ĐỨT ở phần nằm trên mặt KHUẤT (nhận ctx ⇒ kê vào ve-voi).
+/// ve-thiet-dien(ctx, dinh: d, mat: m, mp: mp-qua-3-diem(P, Q, R))
+#let ve-thiet-dien = _voi-ctx(ve-thiet-dien)
+
+// ---------------------------------------------------------------------
+// mat-cong.typ — nón / trụ có NÉT KHUẤT TỰ ĐỘNG (kể cả hai khối che nhau).
+// `mat-non`, `mat-tru`, `dinh-non` TRẢ GIÁ TRỊ ⇒ KHÔNG kê ở đây.
+// `mat-cong` tự tạo khung hình ⇒ cũng KHÔNG kê.
+// ---------------------------------------------------------------------
+/// `ve-mat-cong(..khoi, cam: auto, mau: black, day: 1.1pt, to: none, hien-khuat: true, mau-khuat: auto, day-khuat: auto, n: 48, duong: (), truc: none, truoc: none, them: none)`
+///
+/// Vẽ một hoặc nhiều khối nón/trụ trục đứng, TỰ chia nét liền/đứt bằng cách
+/// bắn tia về phía người nhìn — lo cả tự khuất lẫn hai khối che nhau.
+/// ve-mat-cong(mat-non(r: 2, cao: 4), mat-tru(tam: (0, 2, 0), r: 2, cao: 4))
+#let ve-mat-cong = _voi-ctx(ve-mat-cong)
+/// `ve-truc-3d(cam, x: 3, y: 4, z: 4, am: 0.4, dm: 0.55, ten: ($x$, $y$, $z$), ten-goc: $O$, huong-ten: ("below-left", "below-right", "left"), huong-goc: "below-right", mau: black, day: 0.9pt, cach: 5pt)`
+///
+/// Vẽ hệ trục Oxyz bằng CHÍNH camera của khối ⇒ cả khung hình chung một góc
+/// nghiêng (khác `oxyz`, vốn tự dựng phép chiếu xiên riêng).
+#let ve-truc-3d = _voi-ctx(ve-truc-3d)
diff --git a/packages/preview/conic-toan/0.3.4/lib/bang-thong-ke.typ b/packages/preview/conic-toan/0.3.4/lib/bang-thong-ke.typ
new file mode 100644
index 0000000000..43ee9fac99
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/bang-thong-ke.typ
@@ -0,0 +1,156 @@
+// =====================================================================
+// bang-thong-ke.typ — BẢNG TẦN SỐ (THỐNG KÊ)
+//
+// #import "bang-thong-ke.typ": *
+//
+// 4 loại bảng (bố cục NGANG — giá trị chạy theo cột, đúng kiểu SGK):
+// KHÔNG ghép nhóm, ĐƠN -> bang-tan-so
+// KHÔNG ghép nhóm, ĐÔI -> bang-tan-so-doi
+// CÓ ghép nhóm, ĐƠN -> bang-ghep-nhom
+// CÓ ghép nhóm, ĐÔI -> bang-ghep-nhom-doi
+//
+// "Biến đơn vị của giá trị dấu hiệu": tham số don-vi. Khi có, nhãn ô
+// góc trên-trái hiện thành «tên (đơn vị)», vd: Số tiền (nghìn đồng).
+//
+// Ghi chú chung:
+// gia-tri / tan-so : mảng nội dung hoặc số.
+// moc : mảng mốc để TỰ tạo các khoảng ghép nhóm (n mốc -> n-1 khoảng).
+// nhom : thay cho moc nếu muốn tự truyền sẵn nội dung từng khoảng.
+// =====================================================================
+
+// Nhãn ô góc: gắn thêm (đơn vị) nếu có.
+#let _nhan(ten, don-vi) = if don-vi == none { ten } else { [#ten] + [ (] + [#don-vi] + [)] }
+
+// Một khoảng nửa mở/đóng, vd [35; 40). In-line, hợp gu SGK.
+#let khoang(a, b, dong-trai: true, dong-phai: false) = {
+ let tl = if dong-trai { "[" } else { "(" }
+ let tr = if dong-phai { "]" } else { ")" }
+ [#tl] + [#a] + [; ] + [#b] + [#tr]
+}
+
+// n mốc -> mảng n-1 khoảng [m0;m1), [m1;m2), ...
+#let khoang-tu-moc(moc, dong-trai: true, dong-phai: false) = range(moc.len() - 1).map(
+ i => khoang(moc.at(i), moc.at(i + 1), dong-trai: dong-trai, dong-phai: dong-phai),
+)
+
+// ------- LÕI DỰNG BẢNG -------
+// nhan-gia-tri : nội dung ô góc trên-trái (nhãn hàng giá trị).
+// gia-tri : mảng nhãn các cột giá trị.
+// hang : mảng ((nhãn-hàng, mảng-số), ...) cho các dòng tần số.
+#let _bang(
+ nhan-gia-tri,
+ gia-tri,
+ hang,
+ co-chu: 11pt,
+ mau-vien: black,
+ day-vien: 0.7pt,
+ mau-tieu-de: none, // tô nền cột nhãn (none = không tô)
+ rong-nhan: auto, // bề rộng cột nhãn bên trái
+) = {
+ let n = gia-tri.len()
+ set text(size: co-chu)
+ let o(x) = align(center + horizon, [#x])
+ let nhan(x) = align(left + horizon, strong([#x]))
+
+ let cells = ()
+ // hàng tiêu đề: nhãn giá trị + các giá trị
+ cells.push(if mau-tieu-de == none { nhan(nhan-gia-tri) } else {
+ table.cell(fill: mau-tieu-de, nhan(nhan-gia-tri))
+ })
+ for v in gia-tri { cells.push(o(v)) }
+ // các hàng tần số
+ for hg in hang {
+ let ten = hg.at(0)
+ let mang = hg.at(1)
+ cells.push(if mau-tieu-de == none { nhan(ten) } else {
+ table.cell(fill: mau-tieu-de, nhan(ten))
+ })
+ for t in mang { cells.push(o(t)) }
+ }
+
+ table(
+ columns: (rong-nhan,) + (auto,) * n,
+ inset: (x: 8pt, y: 6pt),
+ align: center + horizon,
+ stroke: day-vien + mau-vien,
+ ..cells,
+ )
+}
+
+// ===================== KHÔNG GHÉP NHÓM =====================
+
+// ĐƠN — một dòng tần số.
+#let bang-tan-so(
+ gia-tri: (),
+ tan-so: (),
+ ten-gia-tri: [Giá trị],
+ ten-tan-so: [Tần số],
+ don-vi: none,
+ ..tuy-chon,
+) = _bang(
+ _nhan(ten-gia-tri, don-vi),
+ gia-tri,
+ ((ten-tan-so, tan-so),),
+ ..tuy-chon,
+)
+
+// ĐÔI — hai dòng tần số (vd Nam / Nữ).
+#let bang-tan-so-doi(
+ gia-tri: (),
+ tan-so-1: (),
+ tan-so-2: (),
+ ten-gia-tri: [Giá trị],
+ ten-1: [Nhóm 1],
+ ten-2: [Nhóm 2],
+ don-vi: none,
+ ..tuy-chon,
+) = _bang(
+ _nhan(ten-gia-tri, don-vi),
+ gia-tri,
+ ((ten-1, tan-so-1), (ten-2, tan-so-2)),
+ ..tuy-chon,
+)
+
+// ======================= CÓ GHÉP NHÓM =======================
+
+// ĐƠN — một dòng tần số. Truyền moc (mốc) hoặc nhom (khoảng sẵn).
+#let bang-ghep-nhom(
+ moc: none,
+ nhom: none,
+ tan-so: (),
+ ten-nhom: [Nhóm],
+ ten-tan-so: [Tần số],
+ don-vi: none,
+ dong-phai: false, // khoảng cuối đóng phải?
+ ..tuy-chon,
+) = {
+ let cac-khoang = if nhom != none { nhom } else { khoang-tu-moc(moc, dong-phai: dong-phai) }
+ _bang(
+ _nhan(ten-nhom, don-vi),
+ cac-khoang,
+ ((ten-tan-so, tan-so),),
+ ..tuy-chon,
+ )
+}
+
+// ĐÔI — hai dòng tần số trên cùng bộ khoảng.
+#let bang-ghep-nhom-doi(
+ moc: none,
+ nhom: none,
+ tan-so-1: (),
+ tan-so-2: (),
+ ten-nhom: [Nhóm],
+ ten-1: [Nhóm 1],
+ ten-2: [Nhóm 2],
+ don-vi: none,
+ dong-phai: false,
+ ..tuy-chon,
+) = {
+ let cac-khoang = if nhom != none { nhom } else { khoang-tu-moc(moc, dong-phai: dong-phai) }
+ _bang(
+ _nhan(ten-nhom, don-vi),
+ cac-khoang,
+ ((ten-1, tan-so-1), (ten-2, tan-so-2)),
+ ..tuy-chon,
+ )
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/bang.typ b/packages/preview/conic-toan/0.3.4/lib/bang.typ
new file mode 100644
index 0000000000..a377a54e67
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/bang.typ
@@ -0,0 +1,905 @@
+// =====================================================================
+// bang.typ — BẢNG BIẾN THIÊN & BẢNG XÉT DẤU
+//
+// #bbt(
+// x: (n mốc, vd ($-oo$, $1$, $+oo$)),
+// dau: 2n-1 phần tử xen kẽ [tại x0, khoảng, tại x1, ...]
+// dùng chuỗi "+", "-", "0", "||", "" hoặc nội dung tuỳ ý
+// (none => ẩn dòng f'),
+// gia-tri: n giá trị của f tại các mốc; tại điểm gián đoạn dùng cặp
+// ($-oo$, $+oo$) — giá trị 2 bên kẹp,
+// huong: n-1 chiều mũi tên: "len" | "xuong" | "ngang";
+// NỬA Ô (07/2026, để 2 mũi tên cùng chiều nằm TRÊN 1 ĐƯỜNG qua
+// giá trị đặt GIỮA ô): "len-duoi" (đáy→giữa) + "len-tren"
+// (giữa→đỉnh); "xuong-tren" (đỉnh→giữa) + "xuong-duoi" (giữa→đáy).
+// Giá trị tại mốc kề đầu mút "giữa" tự đặt chính giữa ô,
+// kep: chỉ số các mốc có kẹp ‖ (tiệm cận đứng), vd (1,),
+// gach: chỉ số các KHOẢNG hàm KHÔNG XÁC ĐỊNH -> gạch chéo, vd (1,)
+// (khoảng k nằm giữa mốc k và k+1; huong tại khoảng đó ghi "ngang";
+// dau tại mốc kề bên ghi "||" -> kẹp ‖ vẽ CAO KÍN dòng dấu ngay
+// giữa cột mốc, dải gạch phủ lấn tới tận kẹp),
+// )
+//
+// #bang-xet-dau(x: (...), dong: ((ten, mang-dau-2n-1), ...), gach: (...))
+// =====================================================================
+#import "ve.typ": doan-pt, da-giac-pt
+#import "do-thi.typ": so-toan, so-can-thuc, cuc-tri-bac-ba, cuc-tri-huu-ti
+
+// Cặp vạch đứng ‖ cao `cao`.
+#let kep-vach(cao, day: 0.7pt) = stack(
+ dir: ltr, spacing: 1.9pt,
+ line(angle: 90deg, length: cao, stroke: day),
+ line(angle: 90deg, length: cao, stroke: day),
+)
+
+// Khối gạch chéo 45° (miền không xác định) — phần tử KHỐI, kích thước
+// tuyệt đối nên đặt trong ô/grid là khít, không dính metric dòng chữ.
+#let o-gach(rong, cao, buoc: 5.5pt, day: 0.5pt, mau: luma(45%)) = block(
+ width: rong, height: cao, clip: true,
+ {
+ let w = rong.pt()
+ let h = cao.pt()
+ let t = -h
+ while t < w {
+ doan-pt((t, h), (t + h, 0), mau: mau, day: day)
+ t = t + buoc.pt()
+ }
+ },
+)
+
+// Đổi ký hiệu dấu -> nội dung.
+// Nhận cả SỐ (AI hay gõ `0` thay vì `"0"`) — quy về chuỗi rồi xử lý như cũ.
+#let ky-hieu-dau(s) = {
+ if type(s) == int or type(s) == float {
+ if s == 0 { $0$ } else { $#s$ }
+ } else if type(s) == str {
+ if s == "+" { $+$ } else if s == "-" { $-$ } else if s == "0" { $0$ }
+ else if s == "||" { kep-vach(1.1em) } else if s == "" { none } else { s }
+ } else { s }
+}
+
+// ---------- TỰ BÙ Ô TRỐNG HAI ĐẦU DÃY DẤU ----------
+// Dãy `dau` (bbt) / mảng dấu mỗi dòng (bang-xet-dau) phải có ĐÚNG 2n−1 phần
+// tử, xen kẽ [tại mốc, trên khoảng, tại mốc, ...] nên HAI ĐẦU luôn là ô
+// TRỐNG "". AI sinh bài rất hay quên đúng hai ô đó (viết ("+","0","-") thay
+// vì ("","+","0","-","")) — trước đây lỗi ra "array index out of bounds",
+// VS Code không báo trước, GV phải tự thêm tay.
+//
+// `_diem-day-dau` chấm điểm một dãy theo quy ước vị trí: "+"/"-" phải nằm ở
+// chỉ số LẺ (trên khoảng), "0"/"||" ở chỉ số CHẴN (tại mốc).
+#let _diem-day-dau(day) = {
+ let d = 0
+ for (j, s) in day.enumerate() {
+ let so = type(s) == int or type(s) == float
+ let k = if so { str(s) } else if type(s) == str { s } else { "?" }
+ if k == "+" or k == "-" { d += (if calc.odd(j) { 1 } else { -1 }) }
+ if k == "0" or k == "||" { d += (if calc.even(j) { 1 } else { -1 }) }
+ }
+ d
+}
+
+// Chuẩn hoá dãy dấu về đúng `m` = 2n−1 phần tử. CHỈ chèn "" vào ĐẦU/CUỐI,
+// không bao giờ chèn vào giữa; chọn cách chèn khớp quy ước nhất, hoà thì ưu
+// tiên chèn CÂN hai đầu. Thiếu quá 2 phần tử hoặc thừa -> báo lỗi rõ ràng.
+#let _chuan-day-dau(day, m, ten: "dau") = {
+ if day == none { return none }
+ if type(day) != array {
+ panic("`" + ten + "` phải là MẢNG " + str(m) + " phần tử (= 2 × số mốc − 1).")
+ }
+ let l = day.len()
+ if l == m { return day }
+ let ns = " Dãy dấu xen kẽ [tại mốc, trên khoảng, tại mốc, ...], hai đầu là ô trống \"\"."
+ if l > m {
+ panic("`" + ten + "` có " + str(l) + " phần tử nhưng bảng cần đúng "
+ + str(m) + " (= 2 × " + str(int((m + 1) / 2)) + " mốc − 1): thừa "
+ + str(l - m) + " phần tử." + ns)
+ }
+ if m - l > 2 {
+ panic("`" + ten + "` chỉ có " + str(l) + " phần tử, bảng cần " + str(m)
+ + " (= 2 × " + str(int((m + 1) / 2)) + " mốc − 1): thiếu " + str(m - l)
+ + " phần tử nên không tự bù được." + ns)
+ }
+ let thieu = m - l
+ let tot = none
+ let diem-tot = -10000
+ for k in range(thieu + 1) {
+ let thu = range(k).map(i => "") + day + range(thieu - k).map(i => "")
+ // ưu tiên điểm khớp quy ước; hoà thì ưu tiên chèn cân hai đầu
+ let uu = _diem-day-dau(thu) * 10 - calc.abs(k - (thieu - k))
+ if uu > diem-tot { diem-tot = uu; tot = thu }
+ }
+ tot
+}
+
+// Đổi độ dài -> số pt (nhận sẵn số thì giữ nguyên).
+#let _pt-so(v) = if type(v) == length { v.pt() } else { v }
+
+// Mũi tên biến thiên trong ô (rong × cao).
+// `y1`, `y2` (tuỳ chọn): TUNG ĐỘ đầu/cuối tính từ MÉP TRÊN ô, y hướng XUỐNG.
+// Dùng để đặt đầu mút mũi tên đúng vào TIM chữ số ở hai mốc kề (lối tikz-tab)
+// — `auto` thì rơi về mốc cũ (sát mép trên/dưới ô).
+#let mui-ten-bbt(rong, cao, huong: "len", mau: black, day: 1pt,
+ y1: auto, y2: auto) = box(
+ width: rong, height: cao,
+ {
+ let w = rong.pt()
+ let h = cao.pt()
+ // (y đầu, y cuối) theo toạ độ trang (y hướng xuống); các hướng "-duoi"/
+ // "-tren" là mũi tên NỬA Ô: đầu mút tại h/2 khớp giá trị đặt giữa ô,
+ // để 2 mũi tên cùng chiều ở 2 khoảng kề nhau nằm trên 1 đường thẳng.
+ let dau-cuoi = (
+ "len": (h - 1.5, 1.5),
+ "len-duoi": (h - 1.5, h / 2),
+ "len-tren": (h / 2, 1.5),
+ "xuong": (1.5, h - 1.5),
+ "xuong-tren": (1.5, h / 2),
+ "xuong-duoi": (h / 2, h - 1.5),
+ "ngang": (h / 2, h / 2),
+ ).at(huong)
+ let ya = if y1 == auto { dau-cuoi.at(0) } else { _pt-so(y1) }
+ let yb = if y2 == auto { dau-cuoi.at(1) } else { _pt-so(y2) }
+ let (p, q) = ((1.5, ya), (w - 1.5, yb))
+ doan-pt(p, q, mau: mau, day: day)
+ let dx = q.at(0) - p.at(0)
+ let dy = q.at(1) - p.at(1)
+ let l = calc.sqrt(dx * dx + dy * dy)
+ let ux = dx / l
+ let uy = dy / l
+ let px = -uy
+ let py = ux
+ let k = 6.5
+ da-giac-pt(
+ (
+ q,
+ (q.at(0) - k * ux + 0.38 * k * px, q.at(1) - k * uy + 0.38 * k * py),
+ (q.at(0) - 0.72 * k * ux, q.at(1) - 0.72 * k * uy),
+ (q.at(0) - k * ux - 0.38 * k * px, q.at(1) - k * uy - 0.38 * k * py),
+ ),
+ to: mau,
+ )
+ },
+)
+
+// Ô giá trị: `vi` là bool (true = đỉnh, false = đáy — form cũ) hoặc
+// alignment dọc (top/horizon/bottom).
+#let o-gia-tri(v, vi, cao) = {
+ let doc = if type(vi) == bool { if vi { top } else { bottom } } else { vi }
+ box(height: cao, align(doc + center, v))
+}
+
+// Ô có kẹp ‖: giá trị trái + kẹp + giá trị phải.
+// `cao` = chiều cao BĂNG nội dung (nơi đặt giá trị, khớp với mũi tên).
+// `cao-kin` = chiều cao TOÀN Ô: vạch ‖ kẻ KÍN từ mép trên tới mép dưới ô
+// (lối tikz-tab), còn giá trị vẫn nằm trong băng nội dung nhờ đệm `dem`.
+// ⚠️ PHẢI dùng `box` (không phải `block`) để bọc đệm dọc: `block` là phần tử
+// KHỐI, bề rộng mặc định NUỐT TRỌN chỗ trống của cột `auto` -> bảng phình ra
+// hết bề ngang trang, các cột mốc bị bóp lại làm số có căn thức XÔ XUỐNG DÒNG.
+#let o-kep(trai, phai, tren-trai, tren-phai, cao, cao-kin: auto, dem: 0pt) = {
+ let hk = if cao-kin == auto { cao } else { cao-kin }
+ grid(
+ columns: 3,
+ column-gutter: 2.6pt,
+ rows: hk,
+ box(height: hk, inset: (y: dem), o-gia-tri(trai, tren-trai, cao)),
+ kep-vach(hk),
+ box(height: hk, inset: (y: dem), o-gia-tri(phai, tren-phai, cao)),
+ )
+}
+
+// ---------- CHUẨN HOÁ MỐC & GIÁ TRỊ ----------
+// AI sinh bài hay gõ x: ("-oo", -3, -1, "+oo") và gia-tri: ("-oo", 5, -5/3,
+// "+oo") — tức CHUỖI "-oo" (in ra chữ "-oo" chứ không ra −∞) và SỐ trần
+// (Typst không nhận số ở vị trí nội dung). Hàm này quy về nội dung toán:
+// chuỗi vô cực -> $-oo$/$+oo$, số nguyên -> $#v$, số thập phân -> so-toan
+// (ra phân số đẹp: -5/3). Nội dung có sẵn ($...$) giữ NGUYÊN.
+#let _chuan-o(v) = {
+ if type(v) == array { return v.map(_chuan-o) }
+ if type(v) == int { return $#v$ }
+ if type(v) == float { return so-toan(v) }
+ if type(v) != str { return v }
+ let t = v.trim()
+ if t in ("-oo", "−oo", "-inf", "-infty", "-∞", "−∞") { $-oo$ }
+ else if t in ("+oo", "oo", "+inf", "inf", "+infty", "infty", "+∞", "∞") { $+oo$ }
+ else { v }
+}
+
+// ---------- BẢNG BIẾN THIÊN ----------
+#let bbt(
+ x: (),
+ dau: none,
+ gia-tri: (),
+ huong: (),
+ kep: (),
+ gach: (), // khoảng không xác định -> gạch chéo
+ ten-x: $x$,
+ ten-fp: $f'(x)$,
+ ten-f: $f(x)$,
+ rong-cot: 2.5cm, // bề rộng mỗi khoảng
+ rong-so-gach: 0.9cm, // bề rộng cột MỐC kề vùng gạch (nhỏ -> số sát mũi tên)
+ cao-bt: 2.3cm, // chiều cao dòng biến thiên
+ mau-mui-ten: blue.darken(20%),
+ co-chu: 11pt,
+) = context {
+ let n = x.len()
+ let m = 2 * n - 1
+ // "-oo"/số trần -> nội dung toán (xem _chuan-o)
+ let x = x.map(_chuan-o)
+ let gia-tri = gia-tri.map(_chuan-o)
+ // tự bù 2 ô trống hai đầu nếu dãy dấu bị thiếu (xem _chuan-day-dau)
+ let dau = _chuan-day-dau(dau, m)
+ // báo lỗi RÕ thay cho "array index out of bounds" khi mảng bị hụt
+ if gia-tri.len() < n {
+ panic("`gia-tri` có " + str(gia-tri.len()) + " phần tử nhưng `x` có "
+ + str(n) + " mốc — cần ĐÚNG " + str(n) + " giá trị (mỗi mốc một giá trị).")
+ }
+ if huong.len() < n - 1 {
+ panic("`huong` có " + str(huong.len()) + " phần tử nhưng `x` có " + str(n)
+ + " mốc — cần ĐÚNG " + str(n - 1) + " chiều mũi tên (\"len\"/\"xuong\"/\"ngang\").")
+ }
+ let inset = 5pt
+ // đệm NGANG nhỏ hơn đệm dọc: số nằm gần mũi tên hơn (lối tikz-tab, nơi
+ // mũi tên nối thẳng từ số này sang số kia)
+ let inset-x = 2.5pt
+ let cao-o = cao-bt - 2 * inset
+ let co-dau = dau != none
+ // ĐO chiều cao thật của từng giá trị -> đặt đầu mút mũi tên đúng vào TIM
+ // chữ số (mũi tên thẳng hàng với ký tự, lối tikz-tab).
+ let cao-cua(v) = measure(text(size: co-chu, v)).height
+ let h-tri = gia-tri.map(v => if type(v) == array { v.map(cao-cua) } else { cao-cua(v) })
+ // dòng dấu cao CỐ ĐỊNH để kẹp ‖ và dải gạch phủ kín từ mép trên tới mép dưới
+ let cao-hang-dau = co-chu * 1.3 + 2 * inset
+ // ô gạch kín (tự đo đúng kích thước ô qua layout)
+ let o-gach-kin = table.cell(inset: 0pt, layout(sz => o-gach(sz.width, sz.height)))
+ // dải gạch phủ nửa ô mốc, cao `cao`
+ let nua-gach(cao) = layout(sz => o-gach(sz.width, cao))
+ // các cột: nhãn + xen kẽ (mốc, khoảng)
+ // mốc kề vùng gạch -> bề rộng cố định nhỏ để nửa ô hẹp (số gần mũi tên)
+ let cols = (auto,) + range(m).map(j => {
+ if calc.odd(j) { rong-cot }
+ else {
+ let i = int(j / 2)
+ if ((i - 1) in gach) or (i in gach) { rong-so-gach } else { auto }
+ }
+ })
+
+ // ----- dòng x -----
+ let hang-x = (align(horizon + center, ten-x),)
+ for j in range(m) {
+ hang-x.push(
+ if calc.even(j) { align(horizon + center, x.at(int(j / 2))) } else { none }
+ )
+ }
+
+ // ----- dòng dấu f' -----
+ // Ô dấu tại MỐC: "||" -> cặp vạch ‖ kẻ KÍN từ mép trên tới mép dưới ô
+ // (bỏ đệm dọc của ô), như tikz-tab; dấu khác vẫn căn giữa như cũ.
+ let o-dau-moc(s, cao) = if s == "||" {
+ table.cell(inset: (x: inset-x, y: 0pt), kep-vach(cao))
+ } else { align(horizon + center, ky-hieu-dau(s)) }
+ let hang-dau = ()
+ if co-dau {
+ hang-dau.push(align(horizon + center, ten-fp))
+ for j in range(m) {
+ if calc.odd(j) {
+ // khoảng: gạch kín ô hoặc ký hiệu dấu
+ hang-dau.push(
+ if int((j - 1) / 2) in gach { o-gach-kin }
+ else { align(horizon + center, ky-hieu-dau(dau.at(j))) }
+ )
+ } else {
+ // mốc: nếu kề vùng gạch thì kẹp ‖ cao kín dòng đặt GIỮA cột
+ // (thẳng hàng với số ở dòng x), gạch phủ nửa ô phía vùng gạch
+ let i = int(j / 2)
+ let trai = (i - 1) in gach
+ let phai = i in gach
+ hang-dau.push(
+ if trai or phai {
+ let giua = if dau.at(j) == "||" { kep-vach(cao-hang-dau) } else {
+ block(height: 100%, inset: (x: 2.5pt), align(horizon + center, ky-hieu-dau(dau.at(j))))
+ }
+ table.cell(inset: 0pt, grid(
+ columns: (1fr, auto, 1fr),
+ rows: cao-hang-dau,
+ if trai { nua-gach(cao-hang-dau) } else { none },
+ giua,
+ if phai { nua-gach(cao-hang-dau) } else { none },
+ ))
+ } else { o-dau-moc(dau.at(j), cao-hang-dau) }
+ )
+ }
+ }
+ }
+
+ // ----- dòng biến thiên -----
+ // vị trí dọc của giá trị tại mốc i: theo ĐẦU MÚT mũi tên chạm vào mốc
+ // (mũi tên kết thúc/bắt đầu ở giữa ô -> giá trị đặt chính giữa "horizon").
+ // Đầu mút mũi tên bên PHẢI mốc (nơi mũi tên huong.at(i) XUẤT PHÁT):
+ let mut-phai = (
+ "len": bottom, "len-duoi": bottom, "len-tren": horizon,
+ "xuong": top, "xuong-tren": top, "xuong-duoi": horizon,
+ )
+ // Đầu mút mũi tên bên TRÁI mốc (nơi mũi tên huong.at(i-1) KẾT THÚC):
+ let mut-trai = (
+ "len": top, "len-tren": top, "len-duoi": horizon,
+ "xuong": bottom, "xuong-duoi": bottom, "xuong-tren": horizon,
+ )
+ // Số bám theo mũi tên KHÔNG "ngang" (nét bằng/vùng gạch không định chiều):
+ // ưu tiên mũi tên trái, nếu trái là "ngang" thì theo mũi tên phải (và ngược
+ // lại) — nhờ vậy mốc kề vùng gạch, số đặt đúng đầu mút mũi tên thật.
+ let vi-tri-tai(i) = {
+ let ben-trai = if i > 0 { huong.at(i - 1) } else { none }
+ let ben-phai = if i < n - 1 { huong.at(i) } else { none }
+ if ben-trai != none and ben-trai != "ngang" {
+ mut-trai.at(ben-trai, default: bottom)
+ } else if ben-phai != none and ben-phai != "ngang" {
+ mut-phai.at(ben-phai, default: bottom)
+ } else { horizon }
+ }
+ // Chiều cao ĐO ĐƯỢC của giá trị tại mốc i, nhìn từ phía `ben` (mốc có kẹp ‖
+ // mang HAI giá trị: trái = giá trị bên trái vạch, phải = bên phải).
+ let cao-tai(i, ben) = {
+ let h = h-tri.at(i)
+ if type(h) == array { if ben == "trai" { h.at(0) } else { h.at(1) } } else { h }
+ }
+ // TUNG ĐỘ (tính từ mép trên BĂNG nội dung cao `cao-o`, y hướng XUỐNG) của
+ // đầu mút mũi tên tại mốc i, nhìn từ phía `ben`.
+ // * Nếu giá trị THẬT SỰ nằm ở đầu mút đó -> ngắm đúng TIM chữ số (đo bằng
+ // `measure`) => mũi tên thẳng hàng với ký tự, lối tikz-tab.
+ // * Nếu không (vd hai mũi tên cùng "len" liên tiếp: số nằm ở ĐỈNH ô bên
+ // trái, mũi tên sau phải xuất phát từ ĐÁY ô) -> giữ sát mép như cũ.
+ let y-tai(i, ben) = {
+ let d = if ben == "trai" {
+ mut-trai.at(huong.at(i - 1), default: bottom)
+ } else {
+ mut-phai.at(huong.at(i), default: bottom)
+ }
+ let co-so = (i in kep) or (d == vi-tri-tai(i))
+ let h = cao-tai(i, ben)
+ if d == top {
+ if co-so { h / 2 } else { 1.5pt }
+ } else if d == bottom {
+ if co-so { cao-o - h / 2 } else { cao-o - 1.5pt }
+ } else { cao-o / 2 }
+ }
+ let hang-f = (align(horizon + center, ten-f),)
+ for j in range(m) {
+ if calc.even(j) {
+ let i = int(j / 2)
+ let v = gia-tri.at(i)
+ let trai = (i - 1) in gach
+ let phai = i in gach
+ if i in kep {
+ let (tr, ph) = if type(v) == array { (v.at(0), v.at(1)) } else { (v, v) }
+ // ‖ kẻ KÍN toàn ô (cao-bt), giá trị vẫn nằm trong băng nội dung
+ hang-f.push(table.cell(inset: (x: inset-x, y: 0pt), o-kep(
+ tr, ph,
+ if i > 0 { mut-trai.at(huong.at(i - 1), default: bottom) } else { bottom },
+ if i < n - 1 { mut-phai.at(huong.at(i), default: bottom) } else { bottom },
+ cao-o, cao-kin: cao-bt, dem: inset,
+ )))
+ } else if trai or phai {
+ // mốc kề vùng gạch: gạch phủ nửa ô tới đúng tim cột (thẳng với ‖ ở trên),
+ // giá trị dạt SÁT về phía mép gạch (trai -> căn trái, phai -> căn phải)
+ let doc = vi-tri-tai(i)
+ let ngang = if trai and phai { center } else if trai { left } else { right }
+ // inset nhỏ ở phía mép gạch, nhỏ luôn phía mũi tên để số gần cả hai
+ // (tên KHÁC `inset-x` ở đầu hàm để khỏi che nhầm)
+ let dem-x = if trai and phai { (x: 1pt) }
+ else if trai { (left: 2pt, right: 1pt) }
+ else { (left: 1pt, right: 2pt) }
+ let o-gia = block(width: 100%, height: 100%, inset: (..dem-x, y: inset), align(doc + ngang, v))
+ hang-f.push(table.cell(inset: 0pt,
+ if trai and phai {
+ grid(columns: (1fr, auto, 1fr), rows: cao-bt,
+ nua-gach(cao-bt), o-gia, nua-gach(cao-bt))
+ } else if trai {
+ grid(columns: (1fr, 1fr), rows: cao-bt, nua-gach(cao-bt), o-gia)
+ } else {
+ grid(columns: (1fr, 1fr), rows: cao-bt, o-gia, nua-gach(cao-bt))
+ }
+ ))
+ } else {
+ hang-f.push(o-gia-tri(v, vi-tri-tai(i), cao-o))
+ }
+ } else {
+ let k = int((j - 1) / 2)
+ hang-f.push(
+ if k in gach { o-gach-kin }
+ else {
+ // đầu mút ngắm đúng TIM giá trị hai mốc kề; "ngang" giữ nằm ngang.
+ // Mũi tên CHIẾM TRỌN bề ngang cột khoảng (ô inset ngang = 0) để nối
+ // liền hai số hai bên như tikz-tab — trước đây hụt 2·(5+3)pt mỗi bên
+ // nên nhìn như mũi tên "rời" khỏi số.
+ let ngang-hoa = huong.at(k) == "ngang"
+ table.cell(inset: (x: 0pt, y: inset), mui-ten-bbt(
+ rong-cot, cao-o,
+ huong: huong.at(k), mau: mau-mui-ten,
+ y1: if ngang-hoa { auto } else { y-tai(k, "phai") },
+ y2: if ngang-hoa { auto } else { y-tai(k + 1, "trai") },
+ ))
+ }
+ )
+ }
+ }
+
+ let so-hang = if co-dau { 3 } else { 2 }
+ set text(size: co-chu)
+ table(
+ columns: cols,
+ rows: if co-dau { (auto, cao-hang-dau, cao-bt) } else { (auto, cao-bt) },
+ inset: (x: inset-x, y: inset),
+ align: center + horizon,
+ stroke: (x, y) => (
+ left: if x <= 1 { 0.7pt } else { none },
+ right: if x == m { 0.7pt } else { none },
+ top: 0.7pt,
+ bottom: if y == so-hang - 1 { 0.7pt } else { none },
+ ),
+ ..hang-x,
+ ..hang-dau,
+ ..hang-f,
+ )
+}
+
+// ---------- BẢNG XÉT DẤU ----------
+// dong: mảng các cặp (ten, mang-dau) — mỗi mảng dấu dài 2n-1.
+#let bang-xet-dau(
+ x: (),
+ dong: (),
+ gach: (), // khoảng không xác định -> gạch chéo
+ ten-x: $x$,
+ rong-cot: 2.2cm,
+ co-chu: 11pt,
+) = {
+ let n = x.len()
+ let m = 2 * n - 1
+ let x = x.map(_chuan-o)
+ let cols = (auto,) + range(m).map(j => if calc.odd(j) { rong-cot } else { auto })
+ // hàng cao cố định để kẹp ‖ và dải gạch phủ kín
+ let cao-hang = co-chu * 1.3 + 14pt
+ let o-gach-kin = table.cell(inset: 0pt, layout(sz => o-gach(sz.width, sz.height)))
+ let nua-gach = layout(sz => o-gach(sz.width, cao-hang))
+
+ let cells = (align(horizon + center, ten-x),)
+ for j in range(m) {
+ cells.push(if calc.even(j) { align(horizon + center, x.at(int(j / 2))) } else { none })
+ }
+ for (ten, mang) in dong {
+ // tự bù 2 ô trống hai đầu cho từng dòng dấu
+ let mang = _chuan-day-dau(mang, m, ten: "mảng dấu của dòng")
+ cells.push(align(horizon + center, ten))
+ for j in range(m) {
+ if calc.odd(j) {
+ cells.push(
+ if int((j - 1) / 2) in gach { o-gach-kin }
+ else { align(horizon + center, ky-hieu-dau(mang.at(j))) }
+ )
+ } else {
+ let i = int(j / 2)
+ let trai = (i - 1) in gach
+ let phai = i in gach
+ cells.push(
+ if trai or phai {
+ let giua = if mang.at(j) == "||" { kep-vach(cao-hang) } else {
+ block(height: 100%, inset: (x: 2.5pt), align(horizon + center, ky-hieu-dau(mang.at(j))))
+ }
+ table.cell(inset: 0pt, grid(
+ columns: (1fr, auto, 1fr),
+ rows: cao-hang,
+ if trai { nua-gach } else { none },
+ giua,
+ if phai { nua-gach } else { none },
+ ))
+ } else if mang.at(j) == "||" {
+ // ‖ kẻ KÍN từ mép trên tới mép dưới ô (bỏ đệm dọc)
+ table.cell(inset: (x: 5pt, y: 0pt), kep-vach(cao-hang))
+ } else { align(horizon + center, ky-hieu-dau(mang.at(j))) }
+ )
+ }
+ }
+ }
+
+ set text(size: co-chu)
+ table(
+ columns: cols,
+ rows: cao-hang,
+ inset: (x: 5pt, y: 7pt),
+ align: center + horizon,
+ stroke: (x, y) => (
+ left: if x <= 1 { 0.7pt } else { none },
+ right: if x == m { 0.7pt } else { none },
+ top: 0.7pt,
+ bottom: if y == dong.len() { 0.7pt } else { none },
+ ),
+ ..cells,
+ )
+}
+
+// ---------- CÁC BẢNG DỰNG SẴN THƯỜNG GẶP ----------
+//
+// FORM MỚI (07/2026) — nhập HỆ SỐ (positional), thuật toán tự tìm cực trị/
+// tiệm cận/chiều biến thiên, phủ MỌI trường hợp, nhãn đẹp qua `so-toan`:
+// #bbt-bac-hai(1, -2, 3) y = x² − 2x + 3
+// #bbt-bac-ba(1, 0, -3, 1) y = x³ − 3x + 1 (2 cực trị/y'=0 kép/đơn điệu)
+// #bbt-trung-phuong(1, -2, 0) y = x⁴ − 2x² (3 hoặc 1 cực trị)
+// #bbt-phan-thuc(2, -1, 1, -1) y = (2x−1)/(x−1) (tự xét dấu ad−bc)
+// #bbt-huu-ti(1, -1, 1, 1, -1) y = (x²−x+1)/(x−1) (2 cực trị/đơn điệu)
+// FORM CŨ (named: a:, x1:, y1:, x0:, yc:, dong-bien:, ...) vẫn chạy song song.
+
+// eps so sánh 0 (hệ số thường là số "đẹp")
+#let _eps-bbt = 0.000001
+
+// BBT hàm bậc hai y = ax² + bx + c.
+// Form mới: bbt-bac-hai(a, b, c). Form cũ: bbt-bac-hai(a: 1, xd: .., yd: ..).
+#let bbt-bac-hai(..he-so, a: 1, xd: $(-b)/(2a)$, yd: $(-Delta)/(4a)$, ten-f: $y$, ten-fp: $y'$) = {
+ let hs = he-so.pos()
+ let (a, xd, yd) = if hs.len() == 3 {
+ let (a, b, c) = hs
+ assert(calc.abs(a) > _eps-bbt, message: "bbt-bac-hai: cần a ≠ 0")
+ (a, so-toan(-b / (2 * a)), so-toan(c - b * b / (4 * a)))
+ } else { (a, xd, yd) }
+ if a > 0 {
+ bbt(
+ x: ($-oo$, xd, $+oo$),
+ dau: ("", "-", "0", "+", ""),
+ gia-tri: ($+oo$, yd, $+oo$),
+ huong: ("xuong", "len"),
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, xd, $+oo$),
+ dau: ("", "+", "0", "-", ""),
+ gia-tri: ($-oo$, yd, $-oo$),
+ huong: ("len", "xuong"),
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ }
+}
+
+// BBT hàm y = √(ax² + bx + c) — CHỈ CÓ form hệ số. Đặt g(x) = ax² + bx + c,
+// Δ = b² − 4ac, đỉnh x₀ = −b/(2a), giá trị đỉnh y₀ = √((4ac − b²)/(4a)).
+// Tự phân 3 trường hợp:
+// a > 0, Δ > 0 (g có 2 nghiệm): TXĐ = (−∞, x₁] ∪ [x₂, +∞); nghịch biến rồi
+// (khoảng giữa gạch chéo — ngoài TXĐ) đồng biến; giá trị 2 nghiệm = 0.
+// a > 0, Δ ≤ 0 (nghiệm kép / vô nghiệm): TXĐ = ℝ; giảm rồi tăng, cực tiểu tại
+// đỉnh. Δ < 0 → cực tiểu y₀ > 0, y' = 0 tại đỉnh; Δ = 0 → y = √a·|x − x₀|,
+// cực tiểu 0, y' KHÔNG xác định tại đỉnh (ghi ‖).
+// a < 0, Δ > 0 (g có 2 nghiệm): TXĐ = [x₁, x₂]; đồng biến rồi nghịch biến,
+// cực đại y₀ tại đỉnh, giá trị 2 nghiệm = 0. (a < 0 mà Δ ≤ 0 → TXĐ rỗng.)
+#let bbt-can-bac-hai-ham-bac-hai(a, b, c, ten-f: $y$, ten-fp: $y'$) = {
+ assert(calc.abs(a) > _eps-bbt, message: "bbt-can-bac-hai-ham-bac-hai: cần a ≠ 0")
+ let delta = b * b - 4 * a * c
+ let x-dinh = so-toan(-b / (2 * a))
+ // giá trị tại đỉnh: y₀ = √((4ac − b²)/(4a)) (đúng cho cả a≷0 khi TXĐ ≠ ∅)
+ let y-dinh = so-can-thuc(0, 1, (4 * a * c - b * b) / (4 * a), 1)
+ if a > 0 {
+ if delta > _eps-bbt {
+ // 2 nghiệm: gạch khoảng giữa (ngoài TXĐ), x₁ < x₂
+ let x1 = so-can-thuc(-b, -1, delta, 2 * a)
+ let x2 = so-can-thuc(-b, 1, delta, 2 * a)
+ bbt(
+ x: ($-oo$, x1, x2, $+oo$),
+ dau: ("", "-", "||", "", "||", "+", ""),
+ gia-tri: ($+oo$, $0$, $0$, $+oo$),
+ huong: ("xuong", "ngang", "len"),
+ gach: (1,),
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ } else if delta < -_eps-bbt {
+ // vô nghiệm: TXĐ = ℝ, cực tiểu y₀ tại đỉnh (y' = 0)
+ bbt(
+ x: ($-oo$, x-dinh, $+oo$),
+ dau: ("", "-", "0", "+", ""),
+ gia-tri: ($+oo$, y-dinh, $+oo$),
+ huong: ("xuong", "len"),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ } else {
+ // nghiệm kép: y = √a·|x − x₀|, cực tiểu 0, y' không xác định tại đỉnh (‖)
+ bbt(
+ x: ($-oo$, x-dinh, $+oo$),
+ dau: ("", "-", "||", "+", ""),
+ gia-tri: ($+oo$, $0$, $+oo$),
+ huong: ("xuong", "len"),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ }
+ } else {
+ // a < 0: TXĐ = [x₁, x₂] chỉ khi Δ > 0
+ assert(delta > _eps-bbt, message: "bbt-can-bac-hai-ham-bac-hai: a < 0 thì cần Δ > 0 (nếu không TXĐ rỗng)")
+ let x1 = so-can-thuc(-b, 1, delta, 2 * a)
+ let x2 = so-can-thuc(-b, -1, delta, 2 * a)
+ bbt(
+ x: (x1, x-dinh, x2),
+ dau: ("", "+", "0", "-", ""),
+ gia-tri: ($0$, y-dinh, $0$),
+ huong: ("len", "xuong"),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ }
+}
+
+// BBT hàm bậc ba.
+// Form mới: bbt-bac-ba(a, b, c, d) — tự phân 3 trường hợp theo Δ' = b² − 3ac:
+// Δ' > 0: 2 cực trị | Δ' = 0: y' = 0 kép (vẫn đơn điệu, có mốc) | Δ' < 0: đơn điệu.
+// Form cũ: bbt-bac-ba(a: 1, x1:, y1:, x2:, y2:) — luôn coi là có 2 cực trị.
+#let bbt-bac-ba(
+ ..he-so,
+ a: 1,
+ x1: $x_1$, y1: $y_(c d)$,
+ x2: $x_2$, y2: $y_(c t)$,
+ ten-f: $y$, ten-fp: $y'$,
+) = {
+ let hs = he-so.pos()
+ if hs.len() == 4 {
+ let (a, b, c, d) = hs
+ assert(calc.abs(a) > _eps-bbt, message: "bbt-bac-ba: cần a ≠ 0")
+ let f = x => ((a * x + b) * x + c) * x + d
+ let delta = b * b - 3 * a * c
+ if delta > _eps-bbt {
+ // 2 cực trị — giá trị CHÍNH XÁC dạng căn thức (cuc-tri-bac-ba)
+ let ct = cuc-tri-bac-ba(a, b, c, d)
+ return bbt-bac-ba(
+ a: a,
+ x1: ct.x1, y1: ct.y1,
+ x2: ct.x2, y2: ct.y2,
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ }
+ if delta < -_eps-bbt {
+ // y' vô nghiệm: đơn điệu, không mốc
+ return if a > 0 {
+ bbt(
+ x: ($-oo$, $+oo$), dau: ("", "+", ""),
+ gia-tri: ($-oo$, $+oo$), huong: ("len",),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 5cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, $+oo$), dau: ("", "-", ""),
+ gia-tri: ($+oo$, $-oo$), huong: ("xuong",),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 5cm,
+ )
+ }
+ }
+ // Δ' = 0: nghiệm kép x₀ = −b/(3a), vẫn đơn điệu nhưng ghi mốc y' = 0.
+ // Hai mũi tên NỬA Ô (đáy→giữa rồi giữa→đỉnh) nằm trên CÙNG 1 đường thẳng
+ // đi qua giá trị mốc đặt chính giữa ô.
+ let t = -b / (3 * a)
+ let y-moc = so-can-thuc(2 * b * b * b - 9 * a * b * c + 27 * a * a * d, 0, 0, 27 * a * a)
+ let (dau0, huong2, dau-vc) = if a > 0 {
+ ("+", ("len-duoi", "len-tren"), ($-oo$, $+oo$))
+ } else {
+ ("-", ("xuong-tren", "xuong-duoi"), ($+oo$, $-oo$))
+ }
+ return bbt(
+ x: ($-oo$, so-toan(t), $+oo$),
+ dau: ("", dau0, "0", dau0, ""),
+ gia-tri: (dau-vc.at(0), y-moc, dau-vc.at(1)),
+ huong: huong2,
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ }
+ // ----- form cũ: 2 cực trị nhập tay -----
+ if a > 0 {
+ bbt(
+ x: ($-oo$, x1, x2, $+oo$),
+ dau: ("", "+", "0", "-", "0", "+", ""),
+ gia-tri: ($-oo$, y1, y2, $+oo$),
+ huong: ("len", "xuong", "len"),
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, x1, x2, $+oo$),
+ dau: ("", "-", "0", "+", "0", "-", ""),
+ gia-tri: ($+oo$, y1, y2, $-oo$),
+ huong: ("xuong", "len", "xuong"),
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ }
+}
+
+// BBT hàm bậc ba đơn điệu (y' không đổi dấu).
+#let bbt-bac-ba-don-dieu(a: 1, ten-f: $y$, ten-fp: $y'$) = {
+ if a > 0 {
+ bbt(
+ x: ($-oo$, $+oo$),
+ dau: ("", "+", ""),
+ gia-tri: ($-oo$, $+oo$),
+ huong: ("len",),
+ ten-f: ten-f, ten-fp: ten-fp,
+ rong-cot: 5cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, $+oo$),
+ dau: ("", "-", ""),
+ gia-tri: ($+oo$, $-oo$),
+ huong: ("xuong",),
+ ten-f: ten-f, ten-fp: ten-fp,
+ rong-cot: 5cm,
+ )
+ }
+}
+
+// BBT hàm trùng phương y = ax⁴ + bx² + c.
+// Form mới: bbt-trung-phuong(a, b, c) — tự phân 2 trường hợp:
+// a·b < 0: 3 cực trị tại ±√(−b/2a) và 0 | còn lại: 1 cực trị tại 0.
+// Form cũ: bbt-trung-phuong(a: 1, x0:, yc:, y0:) — luôn coi là 3 cực trị.
+#let bbt-trung-phuong(
+ ..he-so,
+ a: 1,
+ x0: $x_0$, // = √(-b/2a)
+ yc: $y_1$, // giá trị tại ±x0
+ y0: $y_0$, // giá trị tại 0
+ ten-f: $y$, ten-fp: $y'$,
+) = {
+ let hs = he-so.pos()
+ if hs.len() == 3 {
+ let (a, b, c) = hs
+ assert(calc.abs(a) > _eps-bbt, message: "bbt-trung-phuong: cần a ≠ 0")
+ if a * b < -_eps-bbt {
+ // 3 cực trị — x0 và giá trị cực trị CHÍNH XÁC (kể cả căn thức/phân số)
+ return bbt-trung-phuong(
+ a: a,
+ x0: so-can-thuc(0, 1, -b / (2 * a), 1),
+ yc: so-can-thuc(4 * a * c - b * b, 0, 0, 4 * a),
+ y0: so-toan(c),
+ ten-f: ten-f, ten-fp: ten-fp,
+ )
+ }
+ // 1 cực trị tại x = 0 (b = 0 hoặc a·b > 0)
+ return if a > 0 {
+ bbt(
+ x: ($-oo$, $0$, $+oo$),
+ dau: ("", "-", "0", "+", ""),
+ gia-tri: ($+oo$, so-toan(c), $+oo$),
+ huong: ("xuong", "len"),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, $0$, $+oo$),
+ dau: ("", "+", "0", "-", ""),
+ gia-tri: ($-oo$, so-toan(c), $-oo$),
+ huong: ("len", "xuong"),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ }
+ }
+ // ----- form cũ: 3 cực trị nhập tay -----
+ if a > 0 {
+ bbt(
+ x: ($-oo$, $-$ + x0, $0$, x0, $+oo$),
+ dau: ("", "-", "0", "+", "0", "-", "0", "+", ""),
+ gia-tri: ($+oo$, yc, y0, yc, $+oo$),
+ huong: ("xuong", "len", "xuong", "len"),
+ ten-f: ten-f, ten-fp: ten-fp,
+ rong-cot: 2.1cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, $-$ + x0, $0$, x0, $+oo$),
+ dau: ("", "+", "0", "-", "0", "+", "0", "-", ""),
+ gia-tri: ($-oo$, yc, y0, yc, $-oo$),
+ huong: ("len", "xuong", "len", "xuong"),
+ ten-f: ten-f, ten-fp: ten-fp,
+ rong-cot: 2.1cm,
+ )
+ }
+}
+
+// BBT hàm phân thức y = (ax+b)/(cx+d).
+// Form mới: bbt-phan-thuc(a, b, c, d) — tự tính x₀ = −d/c, y₀ = a/c,
+// chiều biến thiên theo dấu ad − bc.
+// Form cũ: bbt-phan-thuc(x0:, y0:, dong-bien:).
+#let bbt-phan-thuc(
+ ..he-so,
+ x0: $x_0$, y0: $y_0$,
+ dong-bien: true, // y' > 0 trên từng khoảng?
+ ten-f: $y$, ten-fp: $y'$,
+) = {
+ let hs = he-so.pos()
+ let (x0, y0, dong-bien) = if hs.len() == 4 {
+ let (a, b, c, d) = hs
+ assert(calc.abs(c) > _eps-bbt, message: "bbt-phan-thuc: cần c ≠ 0")
+ let D = a * d - b * c
+ assert(calc.abs(D) > _eps-bbt, message: "bbt-phan-thuc: ad − bc = 0 (hàm hằng, không có BBT)")
+ (so-toan(-d / c), so-toan(a / c), D > 0)
+ } else { (x0, y0, dong-bien) }
+ if dong-bien {
+ bbt(
+ x: ($-oo$, x0, $+oo$),
+ dau: ("", "+", "||", "+", ""),
+ gia-tri: (y0, ($+oo$, $-oo$), y0),
+ huong: ("len", "len"),
+ kep: (1,),
+ ten-f: ten-f, ten-fp: ten-fp,
+ rong-cot: 3.2cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, x0, $+oo$),
+ dau: ("", "-", "||", "-", ""),
+ gia-tri: (y0, ($-oo$, $+oo$), y0),
+ huong: ("xuong", "xuong"),
+ kep: (1,),
+ ten-f: ten-f, ten-fp: ten-fp,
+ rong-cot: 3.2cm,
+ )
+ }
+}
+
+// BBT hàm hữu tỉ y = (ax² + bx + c)/(dx + e) — CHỈ CÓ form hệ số.
+// Viết y = px + q + r/(dx + e) với p = a/d, q = (bd − ae)/d², r = c − qe.
+// Tự phân 3 trường hợp: p·r·d > 0: 2 cực trị hai bên tiệm cận đứng |
+// p·r·d < 0: đơn điệu (2 khoảng) | r = 0: suy biến (đường thẳng khuyết điểm).
+#let bbt-huu-ti(a, b, c, d, e, ten-f: $y$, ten-fp: $y'$) = {
+ assert(calc.abs(a) > _eps-bbt and calc.abs(d) > _eps-bbt, message: "bbt-huu-ti: cần a ≠ 0 và d ≠ 0")
+ let x0 = -e / d
+ let p = a / d
+ let q = (b * d - a * e) / (d * d)
+ let r = c - q * e
+ let f = x => (a * x * x + b * x + c) / (d * x + e)
+ if p * r * d > _eps-bbt {
+ // 2 cực trị — hoành độ & giá trị CHÍNH XÁC dạng căn thức (cuc-tri-huu-ti)
+ let ct = cuc-tri-huu-ti(a, b, c, d, e)
+ return if p > 0 {
+ bbt(
+ x: ($-oo$, ct.x1, so-toan(x0), ct.x2, $+oo$),
+ dau: ("", "+", "0", "-", "||", "-", "0", "+", ""),
+ gia-tri: ($-oo$, ct.y1, ($-oo$, $+oo$), ct.y2, $+oo$),
+ huong: ("len", "xuong", "xuong", "len"),
+ kep: (2,),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 2.1cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, ct.x1, so-toan(x0), ct.x2, $+oo$),
+ dau: ("", "-", "0", "+", "||", "+", "0", "-", ""),
+ gia-tri: ($+oo$, ct.y1, ($+oo$, $-oo$), ct.y2, $-oo$),
+ huong: ("xuong", "len", "len", "xuong"),
+ kep: (2,),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 2.1cm,
+ )
+ }
+ }
+ // đơn điệu trên từng khoảng. Giới hạn tại x₀: trái = −sign(rd)·∞,
+ // phải = +sign(rd)·∞; riêng r = 0 (suy biến): 2 bên hữu hạn = px₀ + q.
+ let (trai, phai) = if calc.abs(r) < _eps-bbt {
+ let l = so-toan(p * x0 + q)
+ (l, l)
+ } else if r * d > 0 { ($-oo$, $+oo$) } else { ($+oo$, $-oo$) }
+ if p > 0 {
+ bbt(
+ x: ($-oo$, so-toan(x0), $+oo$),
+ dau: ("", "+", "||", "+", ""),
+ gia-tri: ($-oo$, (trai, phai), $+oo$),
+ huong: ("len", "len"),
+ kep: (1,),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ } else {
+ bbt(
+ x: ($-oo$, so-toan(x0), $+oo$),
+ dau: ("", "-", "||", "-", ""),
+ gia-tri: ($+oo$, (trai, phai), $-oo$),
+ huong: ("xuong", "xuong"),
+ kep: (1,),
+ ten-f: ten-f, ten-fp: ten-fp, rong-cot: 3.2cm,
+ )
+ }
+}
+
+// Bảng xét dấu tam thức bậc hai có 2 nghiệm (dấu theo a).
+// (form hệ số: xem các bbt-* phía trên)
+#let xet-dau-tam-thuc(a: 1, x1: $x_1$, x2: $x_2$, ten: $f(x)$) = {
+ let (ngoai, trong) = if a > 0 { ("+", "-") } else { ("-", "+") }
+ bang-xet-dau(
+ x: ($-oo$, x1, x2, $+oo$),
+ dong: ((ten, ("", ngoai, "0", trong, "0", ngoai, "")),),
+ )
+}
+// (đồng bộ mount 07/2026)
diff --git a/packages/preview/conic-toan/0.3.4/lib/bieu-do-thong-ke.typ b/packages/preview/conic-toan/0.3.4/lib/bieu-do-thong-ke.typ
new file mode 100644
index 0000000000..35200c5bbb
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/bieu-do-thong-ke.typ
@@ -0,0 +1,542 @@
+// =====================================================================
+// bieu-do-thong-ke.typ — BIỂU ĐỒ THỐNG KÊ (CT GDPT 2018)
+//
+// #import "bieu-do-thong-ke.typ": *
+//
+// 5 biểu đồ dựng sẵn (KHÔNG nhận ctx — tự dựng khung như truc-so;
+// vẽ chồng qua `them: ctx => ...` như mọi hình khác):
+// bieu-do-tan-so : HISTOGRAM tần số ghép nhóm — cột DÍNH SÁT nhau
+// (kèm `gap-khuc: true` vẽ chồng đường gấp khúc tần số)
+// da-giac-tan-so : đường gấp khúc tần số (ogive) đứng riêng
+// bieu-do-cot : biểu đồ cột RỜI cho giá trị rời rạc (số hoặc chữ)
+// bieu-do-hop : biểu đồ hộp (box plot) — tứ phân vị Toán 10
+// bieu-do-quat : biểu đồ quạt tròn (pie chart) — chia góc theo %
+//
+// 2 hàm TRẢ GIÁ TRỊ (không vẽ):
+// tu-phan-vi(du-lieu) -> (min:, q1:, q2:, q3:, max:) [SGK 10]
+// tu-phan-vi-ghep-nhom(moc, tan-so) -> (q1:, q2:, q3:) [SGK 11]
+//
+// Dữ liệu vào KHỚP với bang-thong-ke.typ: cùng `moc:` (n+1 mốc -> n nhóm)
+// và `tan-so:` — một nguồn số liệu dùng được cả bảng lẫn biểu đồ.
+// =====================================================================
+#import "ve.typ": *
+#import "do-thi.typ": so-dep, so-toan
+
+// Bảng màu mặc định cho biểu đồ quạt/cột.
+#let _mau-bd = (
+ rgb("#4e79a7"), rgb("#f28e2b"), rgb("#e15759"), rgb("#76b7b4"),
+ rgb("#59a14f"), rgb("#edc949"), rgb("#b07aa1"), rgb("#9c755f"),
+)
+
+// Bước chia "đẹp" 1/2/5·10^k cho trục tung (~muc vạch).
+#let _buoc-dep(vmax, muc: 5) = {
+ let raw = calc.max(vmax, 1e-9) / muc
+ let k = calc.floor(calc.log(raw))
+ let p = calc.pow(10.0, k)
+ let r = raw / p
+ let m = if r <= 1 { 1 } else if r <= 2 { 2 } else if r <= 5 { 5 } else { 10 }
+ m * p
+}
+
+// Chỗ trống phải nới thêm bên PHẢI khung để đặt TÊN TRỤC HOÀNH cạnh mũi tên.
+// Trước đây nhãn đặt "above-left" ngay tại đầu mũi tên nên nằm ĐÈ LÊN biểu đồ
+// (trục vẽ xuyên qua chữ). Nay nhãn lui hẳn sang phải đầu mũi tên, và khung
+// được nới đúng bằng bề rộng chữ để nhãn không tràn ra ngoài hình.
+// TRẢ VỀ số ĐƠN VỊ TOẠ ĐỘ cần cộng thêm vào xmax. PHẢI gọi trong `context`.
+#let _cho-ten-x(ten-x, w, base, co-chu: 10pt, cach: 5pt) = {
+ if ten-x == none { return 0 }
+ let s = measure(text(size: co-chu, ten-x))
+ let k = calc.min((s.width + cach) / w, 0.55)
+ base * k / (1 - k)
+}
+
+// Trục Ox + Oy (mũi tên), nhãn tên trục.
+#let _truc-bd(ctx, x0, xb, ytop, ten-x, ten-y) = {
+ doan(ctx, (x0, 0), (xb, 0), day: 0.9pt)
+ dau-mui-ten(ctx, (x0, 0), (xb, 0))
+ doan(ctx, (x0, 0), (x0, ytop), day: 0.9pt)
+ dau-mui-ten(ctx, (x0, 0), (x0, ytop))
+ if ten-x != none { nhan(ctx, (xb, 0), ten-x, huong: "right", cach: 5pt) }
+ if ten-y != none { nhan(ctx, (x0, ytop), ten-y, huong: "right", cach: 7pt) }
+}
+
+// Vạch chia + số trên trục tung (tại k·buoc) và các đường lưới ngang mờ.
+#let _vach-tung(ctx, x0, xb, buoc, vmax, so: true, luoi: false) = {
+ let dx = 2.6pt / ctx.sx
+ let k = 1
+ while k * buoc <= vmax * 1.02 + buoc * 0.0001 {
+ let y = k * buoc
+ if luoi { doan(ctx, (x0, y), (xb, y), mau: luma(86%), day: 0.5pt) }
+ doan(ctx, (x0 - dx, y), (x0 + dx, y), day: 0.8pt)
+ if so { nhan(ctx, (x0, y), [#so-dep(y)], huong: "left", cach: 5pt) }
+ k += 1
+ }
+}
+
+// Vạch chia + số trên trục hoành tại các hoành độ xs.
+#let _vach-hoanh(ctx, xs, so: true) = {
+ let dy = 2.6pt / ctx.sy
+ for x in xs {
+ doan(ctx, (x, -dy), (x, dy), day: 0.8pt)
+ if so { nhan(ctx, (x, 0), [#so-dep(x)], huong: "below", cach: 5pt) }
+ }
+}
+
+// =====================================================================
+// HISTOGRAM — biểu đồ tần số ghép nhóm, cột dính sát nhau.
+// moc : n+1 mốc SỐ (như bang-ghep-nhom) -> n cột
+// tan-so : n tần số (hoặc tần số tương đối — đổi ten-y cho khớp)
+// gap-khuc: true -> vẽ chồng đường gấp khúc tần số (nối trung điểm đỉnh cột,
+// kéo dài về 0 ở hai nhóm ảo ngoài cùng)
+// =====================================================================
+#let bieu-do-tan-so(
+ moc: (),
+ tan-so: (),
+ w: 9cm, h: 5.5cm,
+ mau: rgb(78, 121, 167, 160), // màu tô cột
+ vien: rgb("#2f5d8a"), // màu viền cột
+ ten-x: none, // nhãn trục hoành (vd [Chiều cao (cm)])
+ ten-y: [Tần số],
+ so-dinh: true, // ghi tần số trên đỉnh mỗi cột
+ so-truc: true, // số trên 2 trục
+ buoc-y: auto, // bước chia trục tung (auto = 1/2/5·10^k)
+ luoi-ngang: false, // lưới ngang mờ theo vạch trục tung
+ gap-khuc: false,
+ mau-gap-khuc: red,
+ co-chu: 10pt,
+ them: none,
+) = {
+ let n = tan-so.len()
+ let m0 = moc.first()
+ let mn = moc.last()
+ let fmax = calc.max(..tan-so)
+ let by = if buoc-y == auto { _buoc-dep(fmax) } else { buoc-y }
+ let b1 = moc.at(1) - m0
+ let bn = mn - moc.at(n - 1)
+ let x0 = m0 - b1 * 0.55 // trục tung đặt hụt sang trái nửa nhóm
+ let xb = mn + bn * 0.7
+ let ytop = calc.max(fmax * 1.18, by)
+ let xa = x0 - (xb - x0) * 0.02
+ context {
+ hinh(
+ w: w, h: h,
+ xmin: xa, xmax: xb + _cho-ten-x(ten-x, w, xb - xa, co-chu: co-chu),
+ ymin: -ytop * 0.16, ymax: ytop * 1.06,
+ co-chu: co-chu,
+ ctx => {
+ if luoi-ngang { _vach-tung(ctx, x0, xb, by, fmax, so: false, luoi: true) }
+ // các cột dính sát nhau
+ for i in range(n) {
+ let (a, b, f) = (moc.at(i), moc.at(i + 1), tan-so.at(i))
+ da-giac(ctx, ((a, 0), (b, 0), (b, f), (a, f)), to: mau, mau: vien, day: 0.9pt)
+ if so-dinh and f > 0 {
+ nhan(ctx, ((a + b) / 2, f), [#so-dep(f)], huong: "above", cach: 4pt)
+ }
+ }
+ // đường gấp khúc tần số chồng lên (kéo về 0 ở 2 nhóm ảo ngoài cùng)
+ if gap-khuc {
+ let pts = ((m0 - b1 / 2, 0),)
+ for i in range(n) {
+ pts.push(((moc.at(i) + moc.at(i + 1)) / 2, tan-so.at(i)))
+ }
+ pts.push((mn + bn / 2, 0))
+ duong-gap-khuc(ctx, pts, mau: mau-gap-khuc, day: 1.1pt)
+ for p in pts { diem(ctx, p, mau: mau-gap-khuc, bk: 1.8pt) }
+ }
+ _truc-bd(ctx, x0, xb, ytop, ten-x, ten-y)
+ _vach-tung(ctx, x0, xb, by, fmax, so: so-truc)
+ _vach-hoanh(ctx, moc, so: so-truc)
+ if them != none { them(ctx) }
+ },
+ )
+ }
+}
+
+// =====================================================================
+// ĐƯỜNG GẤP KHÚC TẦN SỐ (ogive) đứng riêng — nối trung điểm các nhóm.
+// keo-dai: true -> nối thêm về 0 tại trung điểm 2 nhóm ảo ngoài cùng (SGK)
+// =====================================================================
+#let da-giac-tan-so(
+ moc: (),
+ tan-so: (),
+ w: 9cm, h: 5.5cm,
+ mau: rgb("#1d4ed8"),
+ ten-x: none,
+ ten-y: [Tần số],
+ so-dinh: true,
+ so-truc: true,
+ buoc-y: auto,
+ luoi-ngang: false,
+ keo-dai: true,
+ co-chu: 10pt,
+ them: none,
+) = {
+ let n = tan-so.len()
+ let m0 = moc.first()
+ let mn = moc.last()
+ let fmax = calc.max(..tan-so)
+ let by = if buoc-y == auto { _buoc-dep(fmax) } else { buoc-y }
+ let b1 = moc.at(1) - m0
+ let bn = mn - moc.at(n - 1)
+ let x0 = m0 - b1 * 0.55
+ let xb = mn + bn * 0.7
+ let ytop = calc.max(fmax * 1.18, by)
+ let xa = x0 - (xb - x0) * 0.02
+ context {
+ hinh(
+ w: w, h: h,
+ xmin: xa, xmax: xb + _cho-ten-x(ten-x, w, xb - xa, co-chu: co-chu),
+ ymin: -ytop * 0.16, ymax: ytop * 1.06,
+ co-chu: co-chu,
+ ctx => {
+ if luoi-ngang { _vach-tung(ctx, x0, xb, by, fmax, so: false, luoi: true) }
+ let giua = range(n).map(i => ((moc.at(i) + moc.at(i + 1)) / 2, tan-so.at(i)))
+ let pts = if keo-dai {
+ ((m0 - b1 / 2, 0),) + giua + ((mn + bn / 2, 0),)
+ } else { giua }
+ duong-gap-khuc(ctx, pts, mau: mau, day: 1.1pt)
+ for p in pts { diem(ctx, p, mau: mau, bk: 1.9pt) }
+ if so-dinh {
+ for (i, p) in giua.enumerate() {
+ if tan-so.at(i) > 0 { nhan(ctx, p, [#so-dep(p.at(1))], huong: "above", cach: 5pt, mau: mau) }
+ }
+ }
+ _truc-bd(ctx, x0, xb, ytop, ten-x, ten-y)
+ _vach-tung(ctx, x0, xb, by, fmax, so: so-truc)
+ _vach-hoanh(ctx, moc, so: so-truc)
+ if them != none { them(ctx) }
+ },
+ )
+ }
+}
+
+// =====================================================================
+// BIỂU ĐỒ CỘT RỜI — giá trị rời rạc (số HOẶC nhãn chữ), cột cách nhau.
+// gia-tri: mảng số (đặt cột đúng hoành độ) hoặc mảng nội dung/chuỗi
+// (cột đặt đều tại 1, 2, 3, ...)
+// rong : bề rộng cột theo tỉ lệ khoảng cách nhỏ nhất giữa 2 cột
+// =====================================================================
+#let bieu-do-cot(
+ gia-tri: (),
+ tan-so: (),
+ w: 9cm, h: 5.5cm,
+ mau: rgb(78, 121, 167, 160),
+ vien: rgb("#2f5d8a"),
+ ten-x: none,
+ ten-y: [Tần số],
+ rong: 0.5,
+ so-dinh: true,
+ so-truc: true,
+ buoc-y: auto,
+ luoi-ngang: false,
+ co-chu: 10pt,
+ them: none,
+) = {
+ let n = tan-so.len()
+ let bang-so = gia-tri.all(v => type(v) == int or type(v) == float)
+ let xs = if bang-so { gia-tri } else { range(1, n + 1) }
+ // khoảng cách nhỏ nhất giữa 2 cột kề nhau
+ let kc = if n >= 2 {
+ calc.min(..range(n - 1).map(i => xs.at(i + 1) - xs.at(i)))
+ } else { 1 }
+ let nua = kc * rong / 2
+ let fmax = calc.max(..tan-so)
+ let by = if buoc-y == auto { _buoc-dep(fmax) } else { buoc-y }
+ let x0 = xs.first() - kc * 0.8
+ let xb = xs.last() + kc * 0.8
+ let ytop = calc.max(fmax * 1.18, by)
+ let xa = x0 - (xb - x0) * 0.02
+ context {
+ hinh(
+ w: w, h: h,
+ xmin: xa, xmax: xb + _cho-ten-x(ten-x, w, xb - xa, co-chu: co-chu),
+ ymin: -ytop * 0.16, ymax: ytop * 1.06,
+ co-chu: co-chu,
+ ctx => {
+ if luoi-ngang { _vach-tung(ctx, x0, xb, by, fmax, so: false, luoi: true) }
+ for i in range(n) {
+ let (x, f) = (xs.at(i), tan-so.at(i))
+ da-giac(ctx, ((x - nua, 0), (x + nua, 0), (x + nua, f), (x - nua, f)),
+ to: mau, mau: vien, day: 0.9pt)
+ if so-dinh and f > 0 { nhan(ctx, (x, f), [#so-dep(f)], huong: "above", cach: 4pt) }
+ }
+ _truc-bd(ctx, x0, xb, ytop, ten-x, ten-y)
+ _vach-tung(ctx, x0, xb, by, fmax, so: so-truc)
+ // nhãn chân cột: số -> so-dep, chữ -> in nguyên
+ let dy = 2.6pt / ctx.sy
+ for (i, x) in xs.enumerate() {
+ doan(ctx, (x, -dy), (x, dy), day: 0.8pt)
+ if so-truc {
+ let nd = if bang-so { [#so-dep(x)] } else { [#gia-tri.at(i)] }
+ nhan(ctx, (x, 0), nd, huong: "below", cach: 5pt)
+ }
+ }
+ if them != none { them(ctx) }
+ },
+ )
+ }
+}
+
+// =====================================================================
+// TỨ PHÂN VỊ (số liệu KHÔNG ghép nhóm — SGK Toán 10):
+// sắp xếp tăng; Q2 = trung vị; Q1/Q3 = trung vị nửa dưới/nửa trên
+// (n lẻ thì KHÔNG kể Q2 vào hai nửa).
+// =====================================================================
+#let _trung-vi(d) = {
+ let n = d.len()
+ if calc.rem(n, 2) == 1 { d.at(int((n - 1) / 2)) }
+ else { (d.at(int(n / 2) - 1) + d.at(int(n / 2))) / 2 }
+}
+
+#let tu-phan-vi(du-lieu) = {
+ let d = du-lieu.sorted()
+ let n = d.len()
+ let nd = int(calc.floor(n / 2))
+ (
+ min: d.first(),
+ q1: _trung-vi(d.slice(0, nd)),
+ q2: _trung-vi(d),
+ q3: _trung-vi(d.slice(n - nd)),
+ max: d.last(),
+ )
+}
+
+// Tứ phân vị của mẫu GHÉP NHÓM (SGK Toán 11): nội suy trong nhóm chứa Qk,
+// Qk = L + (k·n/4 − cf)/f · (R − L).
+#let tu-phan-vi-ghep-nhom(moc, tan-so) = {
+ let n = tan-so.sum()
+ let q(k) = {
+ let muc = k * n / 4
+ let cf = 0
+ for i in range(tan-so.len()) {
+ let f = tan-so.at(i)
+ if f > 0 and cf + f >= muc {
+ return moc.at(i) + (muc - cf) / f * (moc.at(i + 1) - moc.at(i))
+ }
+ cf += f
+ }
+ moc.last()
+ }
+ (q1: q(1), q2: q(2), q3: q(3))
+}
+
+// =====================================================================
+// BIỂU ĐỒ HỘP (box plot) — nằm ngang trên trục số, gióng đứt xuống trục.
+// du-lieu: mảng số thô (tự tính 5 số tóm tắt theo SGK 10), HOẶC
+// tom-tat: (min, q1, q2, q3, max) — mảng 5 số hay dict cùng khoá.
+// =====================================================================
+#let bieu-do-hop(
+ du-lieu: none,
+ tom-tat: none,
+ w: 10cm, h: 3.9cm,
+ mau: rgb(78, 121, 167, 90),
+ vien: rgb("#2f5d8a"),
+ ten: $x$,
+ so: true, // ghi 5 giá trị dưới trục
+ co-chu: 10pt,
+ them: none,
+) = {
+ let t = if du-lieu != none { tu-phan-vi(du-lieu) }
+ else if type(tom-tat) == array {
+ (min: tom-tat.at(0), q1: tom-tat.at(1), q2: tom-tat.at(2),
+ q3: tom-tat.at(3), max: tom-tat.at(4))
+ } else { tom-tat }
+ let pad = calc.max((t.max - t.min) * 0.14, 0.5)
+ let xa = t.min - pad
+ let xb = t.max + pad * 1.4
+ // hộp ở trên (tâm yc), trục số ở dưới (y = ytr)
+ let yc = 0.42
+ let nb = 0.4 // nửa chiều cao hộp
+ let nr = 0.2 // nửa chiều cao vạch râu min/max
+ let ytr = -0.78 // cao độ trục số
+ context {
+ hinh(
+ w: w, h: h,
+ xmin: xa, xmax: xb + _cho-ten-x(ten, w, xb - xa, co-chu: co-chu),
+ ymin: -1.62, ymax: 1.05, co-chu: co-chu,
+ ctx => {
+ // gióng nét đứt 5 đặc trưng xuống trục
+ for x in (t.min, t.q1, t.q2, t.q3, t.max) {
+ doan(ctx, (x, yc - nb), (x, ytr), mau: luma(55%), day: 0.6pt, dut: true)
+ }
+ // râu + 2 vạch đầu mút
+ doan(ctx, (t.min, yc), (t.q1, yc), day: 1pt)
+ doan(ctx, (t.q3, yc), (t.max, yc), day: 1pt)
+ doan(ctx, (t.min, yc - nr), (t.min, yc + nr), mau: vien, day: 1.2pt)
+ doan(ctx, (t.max, yc - nr), (t.max, yc + nr), mau: vien, day: 1.2pt)
+ // hộp Q1–Q3 + trung vị đậm
+ da-giac(ctx, ((t.q1, yc - nb), (t.q3, yc - nb), (t.q3, yc + nb), (t.q1, yc + nb)),
+ to: mau, mau: vien, day: 1.1pt)
+ doan(ctx, (t.q2, yc - nb), (t.q2, yc + nb), mau: vien, day: 2pt)
+ // trục số + vạch + 5 số
+ doan(ctx, (xa, ytr), (xb, ytr), day: 0.9pt)
+ dau-mui-ten(ctx, (xa, ytr), (xb, ytr))
+ nhan(ctx, (xb, ytr), ten, huong: "right", cach: 5pt)
+ let dv = 2.6pt / ctx.sy
+ for x in (t.min, t.q1, t.q2, t.q3, t.max) {
+ doan(ctx, (x, ytr - dv), (x, ytr + dv), day: 0.9pt)
+ if so { nhan(ctx, (x, ytr), so-toan(x), huong: "below", cach: 6pt) }
+ }
+ if them != none { them(ctx) }
+ })
+ }
+}
+
+// =====================================================================
+// BIỂU ĐỒ QUẠT TRÒN (pie chart) — chia góc theo tỉ lệ %.
+// Mỗi mục positional: (tên, số-liệu) hoặc (tên, số-liệu, màu).
+// Bắt đầu từ 12 giờ, quay theo chiều kim đồng hồ (kiểu SGK).
+// phan-tram: true -> ghi % trong quạt (quạt bé thì ghi kèm tên ngoài)
+// =====================================================================
+#let bieu-do-quat(
+ ..muc,
+ w: 7.5cm,
+ phan-tram: true,
+ vien: white, // màu đường phân cách các quạt
+ co-chu: 10pt,
+ them: none,
+) = {
+ let ms = muc.pos()
+ let tong = ms.map(m => m.at(1)).sum()
+ hinh(
+ w: w, xmin: -1.8, xmax: 1.8, ymin: -1.45, ymax: 1.45, co-chu: co-chu,
+ ctx => {
+ let g = 90deg // bắt đầu 12 giờ
+ for (i, m) in ms.enumerate() {
+ let phan = m.at(1) / tong
+ let g2 = g - phan * 360deg // chiều kim đồng hồ
+ let mq = if m.len() >= 3 { m.at(2) } else { _mau-bd.at(calc.rem(i, _mau-bd.len())) }
+ let pts = ((0, 0),) + diem-cung((0, 0), 1, 1, g2, g, n: calc.max(int(phan * 72), 2))
+ da-giac(ctx, pts, to: mq, mau: vien, day: 1.2pt)
+ // nhãn tại tia phân giác của quạt
+ let gm = g - phan * 180deg
+ let (hx, hy) = (calc.cos(gm), calc.sin(gm))
+ let pc = [#so-dep(calc.round(phan * 100, digits: 1))%]
+ let huong = if hx > 0.35 { "right" } else if hx < -0.35 { "left" }
+ else if hy > 0 { "above" } else { "below" }
+ if phan >= 0.08 {
+ // % trắng đậm trong quạt, tên ngoài
+ if phan-tram {
+ nhan(ctx, (hx * 0.62, hy * 0.62), text(fill: white, weight: "bold", pc),
+ huong: "center")
+ }
+ nhan(ctx, (hx * 1.06, hy * 1.06), [#m.at(0)], huong: huong, cach: 4pt)
+ } else {
+ // quạt bé: tên + % gộp chung bên ngoài
+ let nd = if phan-tram { [#m.at(0) (#pc)] } else { [#m.at(0)] }
+ nhan(ctx, (hx * 1.06, hy * 1.06), nd, huong: huong, cach: 4pt)
+ }
+ g = g2
+ }
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// =====================================================================
+// SỐ ĐẶC TRƯNG MẪU SỐ LIỆU (TRẢ GIÁ TRỊ, không vẽ — dùng thẳng trong lời giải)
+// KHÔNG ghép nhóm: nhận mảng thô, hoặc gia-tri + tan-so: (bảng tần số).
+// GHÉP NHÓM (moc, tan-so): trung bình/phương sai theo GIÁ TRỊ ĐẠI DIỆN
+// (trung điểm nhóm); mốt/trung vị/tứ phân vị NỘI SUY theo SGK 11–12.
+// =====================================================================
+
+// Bung bảng tần số (gia-tri + tan-so) thành mảng thô; tan-so: none -> giữ nguyên.
+#let _bung(du-lieu, tan-so) = {
+ if tan-so == none { return du-lieu }
+ let d = ()
+ for (i, v) in du-lieu.enumerate() {
+ for _ in range(tan-so.at(i)) { d.push(v) }
+ }
+ d
+}
+
+#let so-trung-binh(du-lieu, tan-so: none) = {
+ if tan-so == none { du-lieu.sum() / du-lieu.len() }
+ else {
+ range(du-lieu.len()).map(i => du-lieu.at(i) * tan-so.at(i)).sum() / tan-so.sum()
+ }
+}
+
+// Mốt: TRẢ VỀ MẢNG các giá trị có tần số lớn nhất (mẫu có thể nhiều mốt).
+#let mot(du-lieu, tan-so: none) = {
+ let (gt, ts) = if tan-so != none { (du-lieu, tan-so) } else {
+ let dem = (:)
+ for v in du-lieu {
+ let k = repr(v)
+ if k in dem { dem.insert(k, (v, dem.at(k).at(1) + 1)) }
+ else { dem.insert(k, (v, 1)) }
+ }
+ (dem.values().map(c => c.at(0)), dem.values().map(c => c.at(1)))
+ }
+ let fmax = calc.max(..ts)
+ range(gt.len()).filter(i => ts.at(i) == fmax).map(i => gt.at(i))
+}
+
+#let trung-vi(du-lieu, tan-so: none) = _trung-vi(_bung(du-lieu, tan-so).sorted())
+
+// tu-phan-vi nay nhận thêm tan-so: (bảng tần số) — bọc lại bản gốc phía trên.
+#let _tpv-tho = tu-phan-vi
+#let tu-phan-vi(du-lieu, tan-so: none) = _tpv-tho(_bung(du-lieu, tan-so))
+
+#let khoang-bien-thien(du-lieu, tan-so: none) = {
+ let d = _bung(du-lieu, tan-so)
+ calc.max(..d) - calc.min(..d)
+}
+
+#let khoang-tu-phan-vi(du-lieu, tan-so: none) = {
+ let q = tu-phan-vi(du-lieu, tan-so: tan-so)
+ q.q3 - q.q1
+}
+
+// Phương sai s² (SGK: chia n); hieu-chinh: true -> s'² chia (n − 1).
+#let phuong-sai(du-lieu, tan-so: none, hieu-chinh: false) = {
+ let d = _bung(du-lieu, tan-so)
+ let n = d.len()
+ let m = d.sum() / n
+ d.map(v => (v - m) * (v - m)).sum() / (if hieu-chinh { n - 1 } else { n })
+}
+
+#let do-lech-chuan(du-lieu, tan-so: none, hieu-chinh: false) = calc.sqrt(
+ phuong-sai(du-lieu, tan-so: tan-so, hieu-chinh: hieu-chinh),
+)
+
+// -------------------- GHÉP NHÓM (moc: n+1 mốc, tan-so: n) --------------------
+
+// Giá trị đại diện = trung điểm từng nhóm.
+#let _dai-dien(moc) = range(moc.len() - 1).map(i => (moc.at(i) + moc.at(i + 1)) / 2)
+
+#let so-trung-binh-ghep-nhom(moc, tan-so) = so-trung-binh(_dai-dien(moc), tan-so: tan-so)
+
+// Mốt (SGK 11): nhóm chứa mốt = nhóm tần số LỚN NHẤT (nhiều nhóm bằng nhau
+// thì lấy nhóm đầu); Mo = L + (f₁−f₀)/((f₁−f₀)+(f₁−f₂))·h, f ngoài biên = 0.
+#let mot-ghep-nhom(moc, tan-so) = {
+ let fmax = calc.max(..tan-so)
+ let j = tan-so.position(f => f == fmax)
+ let f0 = if j > 0 { tan-so.at(j - 1) } else { 0 }
+ let f2 = if j < tan-so.len() - 1 { tan-so.at(j + 1) } else { 0 }
+ let h = moc.at(j + 1) - moc.at(j)
+ let ms = (fmax - f0) + (fmax - f2)
+ if ms == 0 { moc.at(j) + h / 2 } else { moc.at(j) + (fmax - f0) / ms * h }
+}
+
+#let trung-vi-ghep-nhom(moc, tan-so) = tu-phan-vi-ghep-nhom(moc, tan-so).q2
+
+#let phuong-sai-ghep-nhom(moc, tan-so, hieu-chinh: false) = phuong-sai(
+ _dai-dien(moc), tan-so: tan-so, hieu-chinh: hieu-chinh,
+)
+
+#let do-lech-chuan-ghep-nhom(moc, tan-so, hieu-chinh: false) = calc.sqrt(
+ phuong-sai-ghep-nhom(moc, tan-so, hieu-chinh: hieu-chinh),
+)
+
+// R = mốc phải nhóm CUỐI có dữ liệu − mốc trái nhóm ĐẦU có dữ liệu.
+#let khoang-bien-thien-ghep-nhom(moc, tan-so) = {
+ let i = tan-so.position(f => f > 0)
+ let j = tan-so.len() - 1 - tan-so.rev().position(f => f > 0)
+ moc.at(j + 1) - moc.at(i)
+}
+
+#let khoang-tu-phan-vi-ghep-nhom(moc, tan-so) = {
+ let q = tu-phan-vi-ghep-nhom(moc, tan-so)
+ q.q3 - q.q1
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/cau-hoi.typ b/packages/preview/conic-toan/0.3.4/lib/cau-hoi.typ
new file mode 100644
index 0000000000..14da8b09ec
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/cau-hoi.typ
@@ -0,0 +1,1076 @@
+// =====================================================================
+// cau-hoi.typ — CÂU HỎI THEO ĐỊNH DẠNG ĐỀ THI 2025
+// #cau-mc(...) Trắc nghiệm nhiều phương án (tự chia cột theo độ dài)
+// #cau-tf(...) Đúng — Sai 4 ý (tuỳ chọn ô tick sát lề phải)
+// #cau-sa(...) Trả lời ngắn
+// #cau-tl(...) Tự luận
+// #cau-hd(...) Hoạt động (thẻ "HĐN", bộ đếm riêng, hình thức như TL)
+// #cau-lt(...) Luyện tập (thẻ "Luyện tập N", chung bộ đếm với khung
+// #luyen-tap của slide.typ)
+// #cau-vdtt(...) Vận dụng thực tế (thẻ "Vận dụng N", bộ đếm riêng)
+//
+// Số câu tự đánh liên tục cho 4 dạng MC/TF/SA/TL; HĐ, Luyện tập, Vận dụng
+// mỗi loại có bộ đếm riêng (không đổi qua bước hoạt hình).
+// CÔNG TẮC ĐÁP ÁN: #bat-dap-an() / #tat-dap-an()
+// KIỂU TIỀN TỐ (thẻ "Câu X", "A.", "a)"): #kieu-cau-hoi(mau: ..., hinh: ...)
+// hinh: "bo-tron" (mặc định) | "chu-nhat" | "luc-giac" | "khong-to"
+// ("khong-to" = không tô nền, chỉ viền + chữ màu — in không bị đen;
+// riêng hồ sơ dethi: bỏ cả viền — "Câu 1." / "A." đậm cùng màu)
+// hien-o: false = ẩn ô tick Đ/S (ds) + ô điền "Trả lời" (tln) toàn bài.
+// -> đổi MỘT LẦN, đồng bộ toàn bài.
+// CHỪA CHỖ TL (bản in): #trong-tl(cao: 5cm) / #khong-trong-tl()
+// =====================================================================
+#import "slide.typ": _buoc-ht, _bd-cau, _bd-vd, _bd-hd, _bd-lt, _bd-vdtt, _bd-tat-ca, _so-moi, _ho-so, _he-so-gian, _cao-that
+
+#let _hien-da = state("ch-hien-da", false)
+
+// Điều kiện hiện đáp án: công tắc bật + (nếu có lo-da) đã đến bước lo-da.
+#let _da-hien(lo-da) = _hien-da.get() and (lo-da == none or _buoc-ht.get() >= lo-da)
+
+#let bat-dap-an() = _hien-da.update(true)
+#let tat-dap-an() = _hien-da.update(false)
+// Đặt lại số thứ tự — MỖI thể loại có bộ đếm & hàm đặt lại RIÊNG:
+// #dat-lai-cau() -> chỉ nhóm "Câu" (tn/ds/tln/tl); KHÔNG đụng vd/hd/lt/vdtt
+// #dat-lai-cau-vd() -> Ví dụ #dat-lai-cau-hd() -> Hoạt động
+// #dat-lai-cau-lt() -> Luyện tập #dat-lai-cau-vdtt() -> Vận dụng thực tế
+// #dat-lai-cau-tat-ca()-> đặt lại CẢ 5 nhóm cùng lúc (hành vi cũ của dat-lai-cau)
+// Không tham số hoặc `(0)` -> đánh lại từ 1; `(3)` -> đánh tiếp từ 4; ...
+#let _dat-lai-bd(bd, n) = {
+ bd.goc.update(n)
+ bd.cnt.update(0)
+}
+#let dat-lai-cau(..so) = _dat-lai-bd(_bd-cau, so.pos().at(0, default: 0))
+#let dat-lai-cau-vd(..so) = _dat-lai-bd(_bd-vd, so.pos().at(0, default: 0))
+#let dat-lai-cau-hd(..so) = _dat-lai-bd(_bd-hd, so.pos().at(0, default: 0))
+#let dat-lai-cau-lt(..so) = _dat-lai-bd(_bd-lt, so.pos().at(0, default: 0))
+#let dat-lai-cau-vdtt(..so) = _dat-lai-bd(_bd-vdtt, so.pos().at(0, default: 0))
+#let dat-lai-cau-tat-ca(..so) = {
+ let n = so.pos().at(0, default: 0)
+ for bd in _bd-tat-ca { _dat-lai-bd(bd, n) }
+}
+
+// ---------- Chừa chỗ trống sau câu TL/HĐ/LT/VDTT (chỉ ở BẢN IN, khi ẩn đáp án) ----------
+// Đổi ĐỒNG BỘ toàn bài, hai tuỳ chọn:
+// #trong-tl(cao: 5cm) -> CHỪA chỗ làm bài sau mỗi câu:
+// • câu có đặt cho-trong: dùng đúng cho-trong đó;
+// • câu KHÔNG đặt cho-trong: chừa mặc định `cao`.
+// #khong-trong-tl() -> KHÔNG chừa chỗ (bỏ qua cả cho-trong) — in gọn.
+// Mặc định (bat: true, cao: 0pt): giữ nguyên hành vi cũ — chỉ chừa đúng
+// theo cho-trong của từng câu. Ở beamer/loigiai (hiện đáp án) tuỳ chọn này
+// không tác dụng vì chỗ đó dành cho lời giải.
+#let _trong-tl = state("ch-trong-tl", (bat: true, cao: 0pt))
+#let trong-tl(cao: 5cm) = _trong-tl.update((bat: true, cao: cao))
+#let khong-trong-tl() = _trong-tl.update(k => (bat: false, cao: k.cao))
+
+#let _xanh = rgb("#0f4c81")
+#let _luc = rgb("#1e8449")
+#let _do = rgb("#a93226")
+
+// ---------- ĐÁNH DẤU ĐÁP ÁN ĐÚNG NGAY TRONG DANH SÁCH ----------
+// Bọc phương án/phát biểu ĐÚNG bằng True(...) — đáp án đi kèm nội dung nên
+// hoán vị/xáo trộn thoải mái, không lệch:
+// #tn([...], ([$A$], True([$B$]), [$C$], [$D$])) -> đáp án B
+// #ds([...], ([a sai], True([b đúng]), True([c]), [d])) -> (S, Đ, Đ, S)
+// (bí danh tiếng Việt: Dung)
+//
+// LỜI GIẢI GẮN LIỀN TỪNG Ý (#ds) — cần khi công cụ trộn HOÁN VỊ các ý:
+// khối lời giải viết cứng "a) … b) …" sẽ sai sau khi xáo, nên đặt lời giải
+// ngay cạnh ý bằng `giai:`; lib tự đánh lại nhãn theo thứ tự SAU hoán vị.
+// #ds([Cho $y = x^3 - 3x$.], (
+// True([$y' = 3x^2 - 3$], giai: [Đạo hàm của $x^n$ là $n x^(n-1)$.]),
+// False([Hàm số không có cực trị], giai: [$y' = 0$ có hai nghiệm phân biệt.]),
+// [Ý sai, không cần giải riêng — viết trần như cũ],
+// ))
+// `loi-giai:` chung vẫn dùng được — in TRƯỚC phần giải theo ý (phần dẫn nhập).
+#let True(nd, giai: none) = (bg-dung: true, nd: nd, giai: giai)
+#let Dung = True
+#let False(nd, giai: none) = (bg-dung: false, nd: nd, giai: giai)
+#let _la-y(x) = type(x) == dictionary and "nd" in x
+#let _la-true(x) = type(x) == dictionary and x.at("bg-dung", default: false)
+#let _bo-true(x) = if _la-y(x) { x.nd } else { x }
+#let _giai-y(x) = if _la-y(x) { x.at("giai", default: none) } else { none }
+
+// Tách danh sách có True(...)/False(...): trả (ds: nội dung thuần,
+// dung: mảng bool, giai: mảng lời giải từng ý — none nếu không khai).
+#let _tach-true(ds) = (
+ ds: ds.map(_bo-true),
+ dung: ds.map(_la-true),
+ giai: ds.map(_giai-y),
+)
+
+// ---------- TỰ THÊM DẤU CHẤM CUỐI PHƯƠNG ÁN / PHÁT BIỂU ----------
+// Phương án của #tn và ý của #ds là một CÂU, đúng chính tả phải kết bằng "."
+// Bộ dò dưới đây duyệt NGƯỢC cây nội dung tìm ký tự có nghĩa cuối cùng:
+// • đã có . ! ? … : ; (hoặc dấu đóng ngoặc kép/ngoặc sau chúng) -> giữ nguyên
+// • kết thúc bằng khối (danh sách, bảng, hình, xuống dòng...) -> BỎ QUA
+// • còn lại (chữ, số, công thức toán, dấu ngoặc) -> thêm "."
+// Tắt toàn bài: #kieu-cau-hoi(cham-cuoi: false); tắt một câu: `cham: false`.
+#let _dau-ket = (".", "!", "?", "…", ":", ";", "。", "!", "?")
+// Dấu "vỏ" bọc ngoài dấu kết: "Anh ấy đi!" / (đúng.) — nhìn xuyên qua.
+#let _dau-vo = ("\"", "'", "”", "’", "»", ")", "]", "}", "›")
+
+// Các phần tử KHỐI: đứng cuối thì không thêm dấu chấm (chấm sẽ rơi xuống dòng).
+// Lưu ý: trong markup, `- a` sinh thẳng `list.item` (chưa gom thành `list`).
+#let _la-khoi(f) = (
+ f == list or f == enum or f == terms or f == table or f == grid
+ or f == list.item or f == enum.item or f == terms.item
+ or f == figure or f == image or f == block or f == place
+ or f == parbreak or f == linebreak or f == line or f == v or f == pagebreak
+)
+// Phần tử "khoảng trắng" của markup (bỏ qua khi dò ngược từ cuối).
+#let _f-space = [ ].func()
+
+// Đoạn văn bản có nghĩa CUỐI CÙNG (none = không tìm được: hình, công thức...).
+#let _van-cuoi(c) = {
+ if c == none { return none }
+ let t = type(c)
+ if t == int or t == float { return str(c) }
+ if t == str {
+ let s = c.trim()
+ return if s == "" { none } else { s }
+ }
+ if t == array {
+ for x in c.rev() {
+ let r = _van-cuoi(x)
+ if r != none { return r }
+ }
+ return none
+ }
+ if t != content { return none }
+ if c.has("text") { return _van-cuoi(c.text) }
+ if c.has("children") { return _van-cuoi(c.children) }
+ if c.has("body") { return _van-cuoi(c.body) }
+ none
+}
+
+// Phân loại phần tử có nghĩa CUỐI CÙNG:
+// 1 = khối (danh sách/bảng/hình/ngắt dòng/công thức giữa dòng) -> KHÔNG chấm
+// 0 = nội dung thường (chữ, số, công thức trong dòng) -> chấm được
+// -1 = không có gì nhìn thấy (khoảng trắng, metadata, cập nhật trạng thái,
+// phần tử bọc rỗng) -> đi tiếp sang phần tử trước đó
+#let _loai-cuoi(c) = {
+ if c == none { return -1 }
+ let t = type(c)
+ if t == str { return if c.trim() == "" { -1 } else { 0 } }
+ if t == int or t == float { return 0 }
+ if t != content { return 0 }
+ let f = c.func()
+ if _la-khoi(f) { return 1 }
+ if f == _f-space or f == metadata { return -1 }
+ // công thức: TRÌNH BÀY GIỮA DÒNG ($ ... $) là khối; trong dòng luôn "thường"
+ if f == math.equation {
+ if c.at("block", default: false) { return 1 }
+ return if _loai-cuoi(c.body) == 1 { 1 } else { 0 }
+ }
+ if c.has("text") { return if type(c.text) == str and c.text.trim() == "" { -1 } else { 0 } }
+ if c.has("children") {
+ for x in c.children.rev() {
+ let r = _loai-cuoi(x)
+ if r >= 0 { return r }
+ }
+ return -1
+ }
+ if c.has("body") { return _loai-cuoi(c.body) }
+ if c.has("child") { return _loai-cuoi(c.child) } // phần tử styled
+ -1
+}
+
+// Thêm "." nếu thiếu (giữ nguyên nội dung gốc trong mọi trường hợp còn lại).
+#let cham-cau(c) = {
+ if c == none or c == [] { return c }
+ if _loai-cuoi(c) != 0 { return c } // kết bằng khối/hình hoặc rỗng -> bỏ qua
+ let s = _van-cuoi(c)
+ if s == none { return [#c#"."] } // chỉ có công thức (vd $1/2$) -> vẫn chấm
+ // bóc các dấu "vỏ" ngoài cùng: Đúng!" / (sai.) -> nhìn thấy ! và .
+ let cl = s.clusters()
+ let i = cl.len() - 1
+ while i >= 0 and _dau-vo.contains(cl.at(i)) { i = i - 1 }
+ if i >= 0 and _dau-ket.contains(cl.at(i)) { return c }
+ [#c#"."]
+}
+
+// ---------- THU THẬP ĐÁP ÁN CHO BẢNG ĐÁP ÁN ----------
+// Mỗi câu tn/ds/tln phát một metadata vô hình (nhãn ). Hàm bang-dap-an
+// (che-do.typ) query theo document-order rồi dựng 3 bảng. loai ∈ "tn"/"ds"/"tln":
+// tn -> da = chữ cái đáp án đúng ("A".."F") hoặc none.
+// ds -> da = mảng bool 4 ý (true = Đ, false = S).
+// tln -> da = đáp án (chuỗi/số/nội dung).
+// khoa: true -> câu này KHÔNG được hoán vị phương án/ý (công cụ trộn đọc cờ này).
+#let _ghi-da(loai, da, khoa: false) = [#metadata((loai: loai, da: da, khoa: khoa)) ]
+
+// Chữ cái đáp án đúng của câu tn (ưu tiên True(...) trong danh sách; nếu không
+// có thì lấy dap-an dạng chữ "B" cũ).
+#let _chu-da-tn(phuong-an, dap-an) = {
+ let vi = phuong-an.map(_la-true).position(d => d)
+ if vi != none { ("A", "B", "C", "D", "E", "F").at(vi) } else { dap-an }
+}
+
+// ---------- Kiểu tiền tố (thẻ) — đồng bộ màu & hình dáng ----------
+// hien-o: hiện ô tick Đ/S (câu ds có o-tick) và ô điền "Trả lời" (câu tln);
+// false = ẨN đồng bộ toàn bài (đề gọn, HS làm thẳng vào phiếu).
+// om-hinh: chữ ÔM hình (phần dư tràn hết bề rộng dưới hình) — xem voi-hinh.
+// eq-dong: ép công thức KHỐI ($ ... $ có khoảng trắng) trong phương án #tn /
+// ý #ds thành công thức TRONG DÒNG — xem `_ep-trong-dong`.
+#let _kieu = state("ch-kieu", (mau: rgb("#0f4c81"), hinh: "bo-tron", hien-o: true, cham: true, om-hinh: true, eq-dong: true))
+
+// Đổi kiểu ở bất kỳ đâu trong tài liệu:
+// #kieu-cau-hoi(mau: rgb("#e67e22"), hinh: "luc-giac", hien-o: false)
+// #kieu-cau-hoi(om-hinh: false) // tắt chế độ chữ ôm hình từ đây trở đi
+// #kieu-cau-hoi(eq-trong-dong: false) // giữ nguyên $ ... $ khối như cũ
+// #kieu-cau-hoi(cao-that: false) // tắt cột chống chiều cao công thức
+// cao-that: công thức TRONG DÒNG có khai đúng chiều cao NÉT VẼ hay không
+// (xem `_chong-net` của slide.typ). Mặc định BẬT — đây là thứ chống dính chữ
+// ở phân số/căn thức mà `gian-dong` không với tới được.
+#let kieu-cau-hoi(mau: auto, hinh: auto, hien-o: auto, cham-cuoi: auto, om-hinh: auto, eq-trong-dong: auto, cao-that: auto) = {
+ _kieu.update(k => (
+ mau: if mau == auto { k.mau } else { mau },
+ hinh: if hinh == auto { k.hinh } else { hinh },
+ hien-o: if hien-o == auto { k.at("hien-o", default: true) } else { hien-o },
+ cham: if cham-cuoi == auto { k.at("cham", default: true) } else { cham-cuoi },
+ om-hinh: if om-hinh == auto { k.at("om-hinh", default: true) } else { om-hinh },
+ eq-dong: if eq-trong-dong == auto { k.at("eq-dong", default: true) } else { eq-trong-dong },
+ ))
+ // Cơ chế cao-that nằm ở state RIÊNG (slide.typ) vì bai-giang/de-toan phải
+ // đọc được nó mà slide.typ thì không import được cau-hoi.typ.
+ if cao-that != auto {
+ _cao-that.update(c => (
+ bat: cao-that,
+ nguong: c.at("nguong", default: 1pt),
+ them: c.at("them", default: 0pt),
+ chia: c.at("chia", default: 0.5),
+ hien: c.at("hien", default: false),
+ ))
+ }
+}
+
+// Ô tick/ô điền có được hiện không (đọc trong context).
+#let _hien-o() = _kieu.get().at("hien-o", default: true)
+
+// Tự thêm dấu chấm cuối phương án/ý hỏi không? (đọc trong context)
+// cham: auto = theo cài đặt toàn bài; true/false = ép riêng câu này.
+#let _cham-ds(ds, cham) = {
+ let bat = if cham == auto { _kieu.get().at("cham", default: true) } else { cham }
+ if bat { ds.map(cham-cau) } else { ds }
+}
+
+// Thẻ tiền tố: nền màu chủ đạo, chữ trắng; 4 chế độ.
+// "bo-tron" | "chu-nhat" | "luc-giac" : TÔ nền màu, chữ trắng.
+// "khong-to" : KHÔNG tô nền — viền mảnh + chữ màu chủ đạo, đậm;
+// in ấn không bị mảng đen (tiết kiệm mực).
+// RIÊNG hồ sơ dethi: bỏ luôn viền — chỉ chữ ĐẬM cùng màu
+// kèm dấu liền kề ("Câu 1.", "A." — ý ds: "a)") kiểu LaTeX;
+// dấu tuỳ chỉnh qua `duoi` (mặc định ".").
+#let _the(nd, mau: auto, duoi: ".") = context {
+ let k = _kieu.get()
+ let m = if mau == auto { k.mau } else { mau }
+ if k.hinh == "khong-to" and _ho-so.get() == "sachdethi" {
+ text(fill: m, weight: "bold")[#nd#duoi]
+ } else if k.hinh == "khong-to" {
+ box(
+ stroke: 0.8pt + m,
+ radius: 3.5pt,
+ inset: (x: 6pt, y: 2.5pt),
+ baseline: 1%,
+ text(fill: m, weight: "bold", size: 0.8em, nd),
+ )
+ } else {
+ let nd2 = text(fill: white, weight: "bold", size: 0.8em, nd)
+ if k.hinh == "luc-giac" {
+ let s = measure(nd2)
+ let w = s.width + 15pt
+ let h = s.height + 5.5pt
+ box(width: w, height: h, baseline: 1%, {
+ place(polygon(
+ fill: m,
+ (h / 2, 0pt), (w - h / 2, 0pt), (w, h / 2),
+ (w - h / 2, h), (h / 2, h), (0pt, h / 2),
+ ))
+ place(center + horizon, nd2)
+ })
+ } else {
+ box(
+ fill: m,
+ radius: if k.hinh == "chu-nhat" { 0pt } else { 3.5pt },
+ inset: (x: 6pt, y: 2.5pt),
+ baseline: 1%,
+ nd2,
+ )
+ }
+ }
+}
+
+// "Câu N (x điểm)" — đầu mỗi câu hỏi.
+// prefix: chữ trên thẻ ("Câu", "Bài", ...); num: auto = đếm tự động,
+// hoặc số/chữ chỉ định (khi chỉ định, bộ đếm KHÔNG tăng).
+#let _dau-cau(diem: none, prefix: "Câu", num: auto) = {
+ let so = if num == auto { _so-moi(_bd-cau) } else { [#num] }
+ _the([#prefix #so])
+ if diem != none {
+ context text(weight: "bold", fill: _kieu.get().mau, size: 0.86em)[ (#diem điểm)]
+ }
+ h(6pt)
+}
+
+// Chừa `lines` dòng trống làm bài (chỉ khi ẨN đáp án — bản in đề thi).
+#let _chua-dong(lines, lo-da) = context {
+ if lines > 0 and not _da-hien(lo-da) { v(lines * 1.55em) }
+}
+
+// Đóng khung cả câu khi boxed: true.
+// Khoảng cách GIỮA HAI CÂU cũng nhân hệ số gian-dong: nếu không, đặt
+// gian-dong: 2 thì các dòng TRONG câu giãn gấp đôi mà "Câu 2" vẫn dán sát ngay
+// dưới ý cuối của câu trước — nhìn như các câu dính chùm vào nhau.
+// Hệ số 1.0 cho đúng 10pt như cũ ⇒ bài cũ không đổi bố cục.
+#let _khung-cau(boxed, than) = context block(
+ width: 100%, above: 10pt * _he-so-gian(), below: 10pt * _he-so-gian(),
+ stroke: if boxed { 0.7pt + luma(45%) } else { none },
+ inset: if boxed { 8pt } else { 0pt },
+ radius: if boxed { 4pt } else { 0pt },
+ than,
+)
+
+// (_khoi-giai/_hien-giai chuyển xuống SAU voi-hinh — nay hỗ trợ hình
+// kèm LỜI GIẢI thông qua voi-hinh.)
+
+// ---------- ÔM HÌNH: cắt nội dung theo ĐOẠN để chữ ôm sát hình ----------
+// Bài toán: hình hẹp + lời giải DÀI ⇒ cột hình trống một khoảng lớn phía
+// dưới, lãng phí giấy. Cách chữa: đặt các ĐOẠN ĐẦU cạnh hình cho vừa đúng
+// chiều cao hình, phần còn lại in NGUYÊN BỀ RỘNG bên dưới hình.
+
+// Tách nội dung thành mảng "khối" (đoạn văn, công thức tách dòng, danh
+// sách, bảng…). Dấu ngắt đoạn KHÔNG giữ lại — ghép lại bằng parbreak().
+// Nội dung ghép từ nhiều mảnh có thể LỒNG sequence trong sequence — trải
+// phẳng (CHỈ trải sequence, không đụng grid/table/list… dù chúng cũng có
+// trường "children").
+#let _f-seq = [].func()
+#let _la-seq(c) = {
+ if type(c) != content { return false }
+ let f = c.func()
+ if f == _f-seq { return true }
+ // Dự phòng (phòng khi `[].func()` không nhận ra): có trường "children"
+ // mà KHÔNG phải các phần tử gom nhóm thật sự.
+ c.has("children") and not (f == grid or f == table or f == list or f == enum
+ or f == terms or f == stack or f == figure)
+}
+#let _phang-seq(c) = {
+ if _la-seq(c) { return c.children.map(_phang-seq).flatten() }
+ (c,)
+}
+
+// Mỗi khối là dict `(nd: nội dung, sep: dấu ngắt đi sau nó)`. PHẢI giữ dấu
+// ngắt GỐC: bản A4 đi qua `_ghep` (che-do.typ) nên mọi dấu ngắt ĐOẠN đã bị
+// đổi thành ngắt DÒNG — ghép lại bằng parbreak sẽ giãn rộng ra, đổi bố cục
+// mọi bài cũ. Vì vậy cũng phải CẮT ở cả linebreak, không riêng parbreak.
+#let _tach-doan(c) = {
+ if c == none { return () }
+ if type(c) != content { return ((nd: c, sep: none),) }
+ let ds = _phang-seq(c)
+ let ra = ()
+ let cum = ()
+ for x in ds {
+ let f = if type(x) == content { x.func() } else { none }
+ if f == parbreak or f == linebreak {
+ if cum.len() > 0 {
+ ra.push((nd: cum.join(), sep: x))
+ cum = ()
+ } else if ra.len() > 0 {
+ // Dấu ngắt đi ngay sau một khối (vd sau công thức tách dòng): gắn
+ // vào khối trước, ĐỪNG bỏ đi kẻo mất một lần xuống dòng.
+ let u = ra.pop()
+ ra.push((nd: u.nd, sep: if u.sep == none { x } else { u.sep + x }))
+ }
+ } else {
+ cum.push(x)
+ if (f == block or f == grid or f == table or f == list or f == enum
+ or f == terms or f == figure
+ or (f == math.equation and x.at("block", default: false))) {
+ ra.push((nd: cum.join(), sep: none))
+ cum = ()
+ }
+ }
+ }
+ if cum.len() > 0 { ra.push((nd: cum.join(), sep: none)) }
+ ra
+}
+
+// Ghép các khối lại NGUYÊN như cũ (kèm dấu ngắt gốc), bỏ dấu ngắt cuối cùng.
+#let _noi-khoi(ds) = {
+ let ra = none
+ for (i, u) in ds.enumerate() {
+ ra = if ra == none { u.nd } else { ra + u.nd }
+ if i + 1 < ds.len() and u.sep != none { ra = ra + u.sep }
+ }
+ if ra == none { [] } else { ra }
+}
+
+// Phần tử KHÔNG xuống dòng được (công thức tách dòng, ảnh, bảng): bề rộng
+// tự nhiên phải lọt cột hẹp, nếu không sẽ tràn ra ngoài lề.
+#let _khoi-cung(x) = {
+ if type(x) != content { return false }
+ let f = x.func()
+ if f == math.equation { return x.at("block", default: false) }
+ (f == image or f == table or f == grid or f == figure)
+}
+
+// Khối có phần nào QUÁ RỘNG so với cột hẹp không? (gọi trong context)
+#let _qua-rong(x, hep) = {
+ if type(x) != content { return false }
+ if _khoi-cung(x) { return measure(x).width > hep }
+ if x.has("children") { return x.children.any(y => _qua-rong(y, hep)) }
+ false
+}
+
+// Số khối ĐẦU đặt vừa cạnh hình (cột rộng `hep`, cao tối đa `cao`).
+// Gọi trong context (dùng measure).
+#let _dem-khoi-om(khoi, hep, cao) = {
+ let k = 0
+ while k < khoi.len() {
+ if _qua-rong(khoi.at(k).nd, hep) { break }
+ if measure(block(width: hep, _noi-khoi(khoi.slice(0, k + 1)))).height > cao { break }
+ k = k + 1
+ }
+ k
+}
+
+// Chế độ ôm có bật không: om = auto → theo kieu-cau-hoi(om-hinh:).
+#let _om-bat(om) = if om == auto { _kieu.get().at("om-hinh", default: true) } else { om }
+
+// ---------- BỐ CỤC ĐỀ + HÌNH ----------
+// Quy tắc chung cho MỌI dạng câu (dùng qua tham số hinh: của
+// cau-mc/cau-tf/cau-sa/cau-tl/... hoặc gọi trực tiếp trong thân câu):
+// • hình HẸP (bề rộng ≤ ti-le × bề rộng khung): hình canh giữa CỘT PHẢI —
+// cột phải ôm đúng bề rộng hình, cột trái (đề + phương án/ý hỏi) nhận
+// phần còn lại; hai cột canh giữa theo chiều dọc nên độ cao cân đối.
+// • hình RỘNG (BBT, bảng thống kê, hình khổ lớn): tự tách ra DÒNG RIÊNG
+// canh giữa — đặt sau phần đề `de`, trước phần `duoi` (phương án/ý hỏi).
+// LƯU Ý: truyền hình TRỰC TIẾP (hinh: ve-do-thi(...)), KHÔNG bọc align/block
+// quanh hình — bọc thêm sẽ đo sai bề rộng.
+// vi-tri ("right" | "left" | "center" | "top" | "bottom"): vị trí hình so với đề;
+// • "right"/"left" — hình nằm cột phải/cột trái, chữ ôm quanh hình;
+// • "center" — hình canh GIỮA trên DÒNG RIÊNG, nằm giữa phần đề và
+// phần phương án/ý hỏi (dùng cho hình khổ lớn: BBT,
+// bảng thống kê, đồ thị rộng);
+// • "top" — hình lên TRÊN cả đề bài;
+// • "bottom" — hình xuống DƯỚI cả phương án.
+// be-rong: auto = ôm đúng bề rộng hình (hình rộng tự xuống dòng riêng),
+// hoặc chỉ định (35%, 5cm...) = cột hình chiếm đúng bề rộng đó.
+// Với "center"/"top"/"bottom" thì be-rong KHÔNG có tác dụng (hình
+// đã chiếm trọn bề ngang).
+// om: chữ ÔM hình — auto = theo kieu-cau-hoi(om-hinh:), mặc định BẬT;
+// false = giữ lối 2 cột cũ (cột hình có thể trống nhiều phía dưới).
+#let voi-hinh(de, hinh, duoi: none, ti-le: 0.46, khoang: 14pt, vi-tri: "right", be-rong: auto, om: auto) = {
+ assert(vi-tri in ("right", "left", "center", "top", "bottom"),
+ message: "vi-tri (fig-pos) chỉ nhận \"right\" | \"left\" | \"center\" | \"top\" | \"bottom\", nhận được: " + repr(vi-tri))
+ let rieng = block(width: 100%, above: 8pt, below: 8pt, align(center, hinh))
+ if hinh == none {
+ de
+ duoi
+ } else if vi-tri == "top" {
+ rieng
+ de
+ duoi
+ } else if vi-tri == "center" {
+ de
+ rieng
+ duoi
+ } else if vi-tri == "bottom" {
+ de
+ duoi
+ rieng
+ } else {
+ let than = { de; duoi }
+ let hai-cot(o-hinh) = if vi-tri == "left" {
+ grid(
+ columns: (if be-rong == auto { auto } else { be-rong }, 1fr),
+ column-gutter: khoang,
+ align: (center + horizon, left + horizon),
+ o-hinh, than,
+ )
+ } else {
+ grid(
+ columns: (1fr, if be-rong == auto { auto } else { be-rong }),
+ column-gutter: khoang,
+ align: (left + horizon, center + horizon),
+ than, o-hinh,
+ )
+ }
+ // Bố cục ÔM (gọi TRONG context): none = không cắt được hoặc không cần
+ // cắt (nội dung vốn đã thấp hơn hình) ⇒ quay về lối 2 cột cũ.
+ let bo-cuc-om(w-hinh, kich) = {
+ let hep = kich.width - w-hinh - khoang
+ if hep < 3cm { return none }
+ let khoi = _tach-doan(than)
+ if khoi.len() < 2 { return none }
+ let k = _dem-khoi-om(khoi, hep, measure(hinh).height)
+ if k == 0 or k == khoi.len() { return none }
+ let tren = _noi-khoi(khoi.slice(0, k))
+ block(above: 0pt, below: 0pt, if vi-tri == "left" {
+ grid(
+ columns: (w-hinh, 1fr), column-gutter: khoang,
+ align: (center + top, left + top),
+ hinh, tren,
+ )
+ } else {
+ grid(
+ columns: (1fr, w-hinh), column-gutter: khoang,
+ align: (left + top, center + top),
+ tren, hinh,
+ )
+ })
+ parbreak()
+ _noi-khoi(khoi.slice(k))
+ }
+ if be-rong != auto {
+ context layout(kich => {
+ let w = if type(be-rong) == ratio { kich.width * be-rong } else if type(be-rong) == length { be-rong } else { none }
+ let r = if _om-bat(om) and w != none { bo-cuc-om(w, kich) } else { none }
+ if r == none { hai-cot(align(center + horizon, hinh)) } else { r }
+ })
+ } else {
+ context layout(kich => {
+ let w = measure(hinh).width
+ if w <= ti-le * kich.width {
+ let r = if _om-bat(om) { bo-cuc-om(w, kich) } else { none }
+ if r == none { hai-cot(hinh) } else { r }
+ } else {
+ de
+ rieng
+ duoi
+ }
+ })
+ }
+ }
+}
+
+// Khối "Hướng dẫn giải" dùng chung cho cả 4 dạng.
+// hinh: hình kèm LỜI GIẢI — bố cục 2 cột/hàng riêng như voi-hinh
+// (vi-tri: "right"|"left"|"center"|"top"|"bottom"; be-rong: auto hoặc chỉ định).
+#let _khoi-giai(lg, hinh: none, vi-tri: "right", be-rong: auto) = block(
+ width: 100%, inset: (left: 11pt, top: 4pt, bottom: 4pt),
+ stroke: (left: 2.5pt + _luc), above: 8pt,
+ voi-hinh({
+ align(center, text(fill: _luc, weight: "bold", size: 0.84em)[Hướng dẫn giải])
+ lg
+ }, hinh, vi-tri: vi-tri, be-rong: be-rong),
+)
+
+// Hiện lời giải theo công tắc + bước `buoc`; trước bước đó KHÔNG chiếm chỗ
+// (tránh phần ẩn tràn trang sinh trang trắng đệm khi trình chiếu).
+#let _hien-giai(loi-giai, buoc, hinh: none, vi-tri: "right", be-rong: auto) = context {
+ if _hien-da.get() and loi-giai != none {
+ if buoc == none or _buoc-ht.get() >= buoc {
+ _khoi-giai(loi-giai, hinh: hinh, vi-tri: vi-tri, be-rong: be-rong)
+ }
+ }
+}
+
+// ---------- MC: trắc nghiệm nhiều phương án ----------
+// Một phương án: thẻ chữ cái + nội dung; phương án đúng tô xanh lá.
+// ---------- ÉP CÔNG THỨC KHỐI THÀNH TRONG DÒNG ----------
+// Người soạn (và nhất là AI sinh bài) rất hay gõ `$ cases(x > 0, y > 0, x + y < 3) $`
+// CÓ khoảng trắng sát hai dấu $ ⇒ Typst hiểu là công thức TRÌNH BÀY GIỮA DÒNG
+// (block) ⇒ phương án bị xuống dòng và canh giữa, cột phình cao, hệ 3 bpt trông
+// khác hẳn hệ 2 bpt viết `$cases(...)$`. Hàm này duyệt nội dung phương án/ý và
+// đổi MỌI equation `block: true` → `block: false`, cho ra đúng dáng trong dòng.
+// AN TOÀN: chỉ đụng tới `math.equation`; nội dung không có công thức khối thì
+// trả về NGUYÊN VẸN (so sánh `==` giữ nguyên đối tượng cũ). KHÔNG đệ quy vào
+// block/grid/table/figure… (phương án là hình, bảng… giữ nguyên bố cục cũ).
+#let _ep-trong-dong(c) = {
+ if type(c) == array { return c.map(_ep-trong-dong) }
+ if type(c) != content { return c }
+ let f = c.func()
+ if f == math.equation {
+ if c.at("block", default: false) { return math.equation(block: false, c.body) }
+ return c
+ }
+ if _la-seq(c) {
+ let ds = c.children.map(_ep-trong-dong)
+ return if ds.len() == 0 { c } else { ds.join() }
+ }
+ c
+}
+
+// Có ép công thức khối thành trong dòng không? (đọc trong context)
+// trong-dong: auto = theo cài đặt toàn bài; true/false = ép riêng câu này.
+#let _ep-ds(ds, trong-dong) = {
+ let bat = if trong-dong == auto { _kieu.get().at("eq-dong", default: true) } else { trong-dong }
+ if bat { ds.map(_ep-trong-dong) } else { ds }
+}
+
+#let _o-pa(chu, noi-dung, dung) = {
+ if dung {
+ box(fill: _luc.lighten(87%), radius: 3.5pt, inset: (x: 5pt, y: 3.5pt))[
+ #_the(chu, mau: _luc) #noi-dung
+ ]
+ } else {
+ [#_the(chu) #noi-dung]
+ }
+}
+
+// cot: auto = tự chọn 4 / 2 / 1 cột theo độ dài phương án dài nhất,
+// hoặc số cột cố định (1, 2, 4).
+// lo-da: bước lộ đáp án; loi-giai + lo-giai: lời giải và bước lộ lời giải.
+// Phương án đúng: bọc True(...) trong danh sách (ưu tiên) hoặc dap-an: "B" (cũ).
+#let cau-mc(
+ cau, phuong-an, dap-an: none, cot: auto, diem: none, hinh: none,
+ lo-da: none, loi-giai: none, lo-giai: auto, cham: auto, khoa-pa: false,
+ trong-dong: auto,
+ fig-pos: "right", fig-width: auto, lines: 0, num: auto, prefix: "Câu", boxed: false,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto,
+) = {
+ _ghi-da("tn", _chu-da-tn(phuong-an, dap-an), khoa: khoa-pa)
+ _khung-cau(boxed, {
+ let hinh-giai = if hinh-giai != none { hinh-giai } else { fig-giai }
+ let t = _tach-true(phuong-an)
+ let pa = t.ds
+ let vi-dung = t.dung.position(d => d)
+ let da = if vi-dung != none { ("A", "B", "C", "D", "E", "F").at(vi-dung) } else { dap-an }
+ voi-hinh({ _dau-cau(diem: diem, prefix: prefix, num: num); cau }, hinh,
+ vi-tri: fig-pos, be-rong: fig-width, duoi: {
+ // khoảng cách ĐỀ → phương án cũng theo hệ số gian-dong (1.0 = 6pt như cũ)
+ context v(6pt * _he-so-gian())
+ context {
+ let hien = _da-hien(lo-da) and da != none
+ // ÉP TRONG DÒNG TRƯỚC rồi mới chấm câu: sau khi ép, `$ ... $` không còn
+ // là "khối" nên `cham-cau` thêm được dấu chấm — đúng như hệ 2 bpt.
+ let pa = _cham-ds(_ep-ds(pa, trong-dong), cham)
+ let cells = pa.enumerate().map(p => {
+ let chu = ("A", "B", "C", "D", "E", "F").at(p.at(0))
+ _o-pa(chu, p.at(1), hien and chu == da)
+ })
+ layout(kich => {
+ let so-cot = if cot == auto {
+ // đo phương án dài nhất, cộng bề rộng thẻ + khoảng đệm
+ let rong = calc.max(..pa.map(x => measure([#x]).width)) + 42pt
+ if 4 * rong <= kich.width { 4 } else if 2 * rong <= kich.width { 2 } else { 1 }
+ } else { cot }
+ grid(
+ columns: (1fr,) * so-cot,
+ // gian-dong với tới CẢ khoảng cách giữa các hàng phương án
+ // (hệ số 1.0 = 9pt như cũ ⇒ bài cũ không đổi bố cục).
+ row-gutter: 9pt * _he-so-gian(), column-gutter: 12pt,
+ ..cells,
+ )
+ })
+ }
+ })
+ _chua-dong(lines, lo-da)
+ _hien-giai(loi-giai, if lo-giai == auto { lo-da } else { lo-giai },
+ hinh: hinh-giai, vi-tri: fig-giai-pos, be-rong: fig-giai-width)
+ },
+ )
+}
+
+// ---------- DÒ NHÃN THỨ TỰ GÕ TAY (chống lặp "a) a) …") ----------
+// Người soạn (hoặc AI sinh bài) hay tự gõ sẵn "a)", "1.", "(b)"… vào đầu item,
+// trong khi cot-item vốn TỰ đánh nhãn ⇒ ra "a) a) Phải quay…". Bộ dò dưới đây
+// đọc CHUỖI ĐẦU của từng item; item nào đã có nhãn thì cot-item bỏ nhãn tự động.
+// NHẬN: a) a. a: A) (a) [b] 1) 1. (2) ii) IV.
+// KHÔNG nhận: "0,5 lít" · "0.5 lít" (sau dấu là chữ số) · "Ta có" · "$x = 1$"
+#let _dau-nhan = (")", ".", ":")
+#let _chu-cai = "abcdefghijklmnopqrstuvwxyz"
+#let _chu-so = "0123456789"
+#let _so-la-ma = ("i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x",
+ "xi", "xii")
+
+// Chuỗi văn bản ĐẦU của một nội dung (chỉ cần vài ký tự đủ soi nhãn).
+// Gặp "vật cản" (toán, hình, bảng, danh sách) thì trả "$" để chắc chắn không khớp.
+#let _dau-van(c, gh: 10) = {
+ if c == none { return "" }
+ let t = type(c)
+ if t == int or t == float { return str(c) }
+ if t == str { return c }
+ if t == array {
+ let s = ""
+ for x in c {
+ s = s + _dau-van(x, gh: gh)
+ if s.trim(at: start).clusters().len() >= gh { break }
+ }
+ return s
+ }
+ if t != content { return "" }
+ let f = c.func()
+ if f == _f-space or f == linebreak or f == parbreak { return " " }
+ if f == metadata { return "" }
+ if _la-khoi(f) or f == math.equation { return "$" }
+ if c.has("text") { return if type(c.text) == str { c.text } else { "$" } }
+ if c.has("children") { return _dau-van(c.children, gh: gh) }
+ if c.has("body") { return _dau-van(c.body, gh: gh) }
+ if c.has("child") { return _dau-van(c.child, gh: gh) } // phần tử styled
+ ""
+}
+
+// Thứ tự của nhãn gõ tay ở đầu item: 1 cho a)/1)/(a), 2 cho b)/2)… ;
+// none = không thấy nhãn nào.
+#let _thu-nhan-tay(c) = {
+ let cl = _dau-van(c).trim(at: start).clusters()
+ if cl.len() == 0 { return none }
+ let mo = (cl.at(0) == "(" or cl.at(0) == "[")
+ let i = if mo { 1 } else { 0 }
+ // thân nhãn: TOÀN chữ cái hoặc TOÀN chữ số, tối đa 4 ký tự
+ let than = ""
+ let loai = none
+ while i < cl.len() and than.clusters().len() < 4 {
+ let l = lower(cl.at(i))
+ let la-so = (_chu-so.contains(l) and loai != "chu")
+ let la-chu = (_chu-cai.contains(l) and loai != "so")
+ if la-so { loai = "so" } else if la-chu { loai = "chu" } else { break }
+ than = than + l
+ i = i + 1
+ }
+ if loai == none or i >= cl.len() { return none }
+ // dấu ngăn: trong ngoặc thì phải đóng ngoặc, ngoài ngoặc thì ) . :
+ let d = cl.at(i)
+ let hop-le = if mo { d == ")" or d == "]" } else { _dau-nhan.contains(d) }
+ if not hop-le { return none }
+ // Sau dấu CHẤM / HAI CHẤM bắt buộc là khoảng trắng hoặc hết chuỗi, kẻo nhận
+ // nhầm "0.5 lít" hay "12:30". Sau dấu ĐÓNG NGOẶC thì "a)Nội dung" vẫn tính.
+ i = i + 1
+ let can-cach = (d == "." or d == ":")
+ if can-cach and i < cl.len() and cl.at(i).trim() != "" { return none }
+ if loai == "so" {
+ if than.starts-with("0") { return none } // "01." không phải nhãn
+ let k = int(than)
+ return if k >= 1 and k <= 99 { k } else { none }
+ }
+ if than.clusters().len() == 1 {
+ let k = _chu-cai.clusters().position(x => x == than)
+ return if k == none { none } else { k + 1 }
+ }
+ let k = _so-la-ma.position(x => x == than)
+ if k == none { none } else { k + 1 }
+}
+
+// Quyết định BỎ nhãn tự động cho từng item:
+// • MỌI item đều đã có nhãn tay -> bỏ hết (kể cả khi đánh tiếp d), e), f)).
+// • chỉ VÀI item có nhãn tay -> chỉ bỏ ở item mà nhãn tay ĐÚNG thứ tự của nó
+// (tránh nhận nhầm câu mở đầu bằng "A. B. C thẳng hàng").
+#let _bo-nhan-tu-dong(items) = {
+ let n = items.len()
+ let thu = items.map(_thu-nhan-tay)
+ let co = thu.filter(x => x != none).len()
+ if co == n and n > 0 { range(n).map(i => true) } else {
+ range(n).map(i => thu.at(i) == i + 1)
+ }
+}
+
+// ---------- CHIA CỘT CÁC ITEM (dùng trong thân câu, nhất là TL) ----------
+// Tự đánh nhãn a) b) c)... và xếp các item thành nhiều cột.
+// so-cot: auto => ĐO item dài nhất rồi chọn số cột lớn nhất còn vừa bề rộng
+// (thử lần lượt trong `muc-cot`); hoặc số cột cố định.
+// theo-cot: true => xếp DỌC theo cột (a,b,c | d,e,f); false => theo hàng.
+// kieu-nhan: mẫu numbering ("a)", "1)", ...); none => không đánh nhãn.
+// do-nhan-tay: true (mặc định) => item nào ĐÃ gõ sẵn "a)", "1.", "(b)"… thì
+// KHÔNG đánh nhãn tự động nữa (chống lặp "a) a) …"); false => luôn đánh.
+//
+// LƯU Ý (beamer): các `\` NẰM TRONG một item chỉ là xuống dòng thường —
+// KHÔNG tạo bước hoạt hình, vì chúng nằm trong lưới nên bộ tách bước ở cấp
+// cao nhất của loi-giai không thấy. Cả item (thậm chí cả khối cot-item) hiện
+// MỘT LƯỢT trong một bước. Muốn hiện dần từng cột theo bước thì bọc từng item
+// bằng lo(n)[...], ví dụ: cot-item(so-cot: 2, lo(2)[...], lo(3)[...]).
+// Cần hiện dần từng dòng như bình thường thì viết thẳng với `\`, đừng cho vào
+// cot-item.
+#let cot-item(
+ ..noi-dung,
+ so-cot: auto,
+ muc-cot: (4, 3, 2, 1), // các mức cột sẽ thử khi so-cot = auto (ưu tiên lớn)
+ dem: 42pt, // đệm mỗi cột (nhãn + khoảng cách), như cau-mc
+ theo-cot: true,
+ kieu-nhan: "a)",
+ do-nhan-tay: true, // dò nhãn a)/1)… gõ sẵn trong item -> không đánh chồng
+ cach-cot: 14pt,
+ cach-hang: auto, // auto = 8pt × hệ số gian-dong (1.0 ⇒ 8pt như cũ)
+) = {
+ let items = noi-dung.pos()
+ let n = items.len()
+ if n == 0 { return }
+ let bo = if do-nhan-tay { _bo-nhan-tu-dong(items) } else { range(n).map(i => false) }
+ let nhan(i) = if kieu-nhan == none or bo.at(i) { [] } else {
+ [#numbering(kieu-nhan, i + 1) ]
+ }
+ let o(i) = box(width: 100%, [#nhan(i)#items.at(i)])
+ context layout(kich => {
+ let so = if so-cot == auto {
+ // đo item dài nhất (kèm nhãn) + đệm để tính số cột vừa khung
+ let rong = calc.max(..range(n).map(i => measure([#nhan(i)#items.at(i)]).width)) + dem
+ let vua = muc-cot.filter(k => k * rong <= kich.width)
+ if vua.len() > 0 { calc.max(..vua) } else { 1 }
+ } else { so-cot }
+ let so = calc.min(so, n)
+ let so-hang = calc.ceil(n / so)
+ let cells = ()
+ for r in range(so-hang) {
+ for c in range(so) {
+ let idx = if theo-cot { c * so-hang + r } else { r * so + c }
+ cells.push(if idx < n { o(idx) } else { [] })
+ }
+ }
+ grid(
+ columns: (1fr,) * so,
+ column-gutter: cach-cot,
+ row-gutter: if cach-hang == auto { 8pt * _he-so-gian() } else { cach-hang },
+ align: left + top,
+ ..cells,
+ )
+ })
+}
+
+// ---------- TF: đúng — sai ----------
+// o-tick: true => mỗi phát biểu có 2 ô Đ/S dóng thẳng sát lề phải
+// (khi hiện đáp án sẽ tick ✓ vào ô tương ứng); false => nhãn Đ/S sau câu.
+// Phát biểu ĐÚNG: bọc True(...) trong danh sách (ưu tiên) hoặc
+// dap-an: (false, true, ...) như cũ.
+// giai: của True/False -> lời giải riêng từng ý, tự đánh nhãn theo thứ tự hiện
+// tại nên hoán vị không lệch. khoa-y: true -> báo công cụ trộn ĐỪNG xáo các ý
+// (dùng cho câu mà ý sau dựa vào kết quả ý trước).
+#let cau-tf(
+ cau, cac-y, dap-an: none, diem: none, hinh: none,
+ lo-da: none, loi-giai: none, lo-giai: auto, o-tick: false, cham: auto,
+ khoa-y: false, trong-dong: auto,
+ fig-pos: "right", fig-width: auto, lines: 0, num: auto, prefix: "Câu", boxed: false,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto,
+) = {
+ _ghi-da("ds", if _tach-true(cac-y).dung.any(d => d) { _tach-true(cac-y).dung } else { dap-an },
+ khoa: khoa-y)
+ _khung-cau(boxed, {
+ let hinh-giai = if hinh-giai != none { hinh-giai } else { fig-giai }
+ let t = _tach-true(cac-y)
+ let cac-y = t.ds
+ let dap-an = if t.dung.any(d => d) { t.dung } else { dap-an }
+ // Lời giải riêng từng ý -> ghép thành khối "a) … b) …" theo thứ tự HIỆN TẠI
+ let loi-giai = if t.giai.any(g => g != none) {
+ let nhan-y = ("a", "b", "c", "d", "e", "f")
+ {
+ if loi-giai != none { loi-giai; v(3pt) }
+ for (j, g) in t.giai.enumerate() {
+ if g != none {
+ block(above: 5pt, below: 5pt, {
+ _the(nhan-y.at(j), duoi: ")"); [ ]; g
+ })
+ }
+ }
+ }
+ } else { loi-giai }
+ voi-hinh({ _dau-cau(diem: diem, prefix: prefix, num: num); cau }, hinh,
+ vi-tri: fig-pos, be-rong: fig-width, duoi: {
+ context v(4pt * _he-so-gian())
+ context {
+ let hien = _da-hien(lo-da) and dap-an != none
+ let cac-y = _cham-ds(_ep-ds(cac-y, trong-dong), cham)
+ let nhan-y = ("a", "b", "c", "d", "e")
+ let k-mau = _kieu.get().mau
+ if o-tick and _hien-o() {
+ let o-vuong(tick, m) = box(
+ width: 15pt, height: 15pt, stroke: 0.8pt + luma(45%), radius: 2pt,
+ fill: if tick { m.lighten(80%) } else { white },
+ align(center + horizon,
+ if tick { text(fill: m, weight: "bold", size: 9pt)[✓] }),
+ )
+ grid(
+ columns: (1fr, 26pt, 26pt),
+ align: (left + horizon, center + horizon, center + horizon),
+ row-gutter: 8pt * _he-so-gian(), column-gutter: 6pt,
+ [], text(weight: "bold", fill: k-mau, size: 0.82em)[Đ],
+ text(weight: "bold", fill: k-mau, size: 0.82em)[S],
+ ..cac-y.enumerate().map(p => (
+ [#_the(nhan-y.at(p.at(0)), duoi: ")") #p.at(1)],
+ o-vuong(hien and dap-an.at(p.at(0)), _luc),
+ o-vuong(hien and not dap-an.at(p.at(0)), _do),
+ )).flatten(),
+ )
+ } else {
+ let gy = 6pt * _he-so-gian() // gian-dong với tới khoảng cách giữa các ý
+ for j in range(cac-y.len()) {
+ block(above: gy, below: gy, {
+ _the(nhan-y.at(j), duoi: ")")
+ [ ]
+ cac-y.at(j)
+ if hien {
+ h(7pt)
+ let dung = dap-an.at(j)
+ let m = if dung { _luc } else { _do }
+ box(fill: m.lighten(86%), radius: 3pt, inset: (x: 6pt, y: 1.5pt),
+ text(size: 0.78em, weight: "bold", fill: m, if dung { "Đ" } else { "S" }))
+ }
+ })
+ }
+ }
+ }
+ })
+ _chua-dong(lines, lo-da)
+ _hien-giai(loi-giai, if lo-giai == auto { lo-da } else { lo-giai },
+ hinh: hinh-giai, vi-tri: fig-giai-pos, be-rong: fig-giai-width)
+ },
+ )
+}
+
+// ---------- SA: trả lời ngắn ----------
+// Khi ẩn đáp án: hiện các ô vuông để điền (như phiếu trả lời).
+// Đáp án đặt NGAY SAU đề (positional thứ 2, ưu tiên) hoặc dap-an: (cũ):
+// #cau-sa([Đề...], [$6$], loi-giai: [...])
+// show-boxes/box-count: hiện/số ô điền (bí danh cũ o-dien vẫn chạy).
+#let cau-sa(
+ cau, ..tra-loi, dap-an: none, diem: none, o-dien: 4, hinh: none,
+ lo-da: none, loi-giai: none, lo-giai: auto,
+ fig-pos: "right", fig-width: auto, show-boxes: true, box-count: auto,
+ lines: 0, num: auto, prefix: "Câu", boxed: false,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto,
+) = {
+ _ghi-da("tln", if tra-loi.pos().len() > 0 { tra-loi.pos().first() } else { dap-an })
+ _khung-cau(boxed, {
+ let hinh-giai = if hinh-giai != none { hinh-giai } else { fig-giai }
+ let dap-an = if tra-loi.pos().len() > 0 { tra-loi.pos().first() } else { dap-an }
+ let so-o = if box-count == auto { o-dien } else { box-count }
+ voi-hinh({ _dau-cau(diem: diem, prefix: prefix, num: num); cau }, hinh,
+ vi-tri: fig-pos, be-rong: fig-width, duoi: {
+ context v(6pt * _he-so-gian())
+ context {
+ if _da-hien(lo-da) and dap-an != none {
+ text(weight: "bold", fill: _luc)[Đáp án: ]
+ text(fill: _luc, dap-an)
+ } else if show-boxes and _hien-o() {
+ [Trả lời: ]
+ box(baseline: 32%, stack(
+ dir: ltr, spacing: 4pt,
+ ..range(so-o).map(k => rect(width: 21pt, height: 21pt, stroke: 0.8pt + _xanh, radius: 2pt)),
+ ))
+ }
+ }
+ })
+ _chua-dong(lines, lo-da)
+ _hien-giai(loi-giai, if lo-giai == auto { lo-da } else { lo-giai },
+ hinh: hinh-giai, vi-tri: fig-giai-pos, be-rong: fig-giai-width)
+ },
+ )
+}
+
+// ---------- Thân chung cho các dạng "kiểu tự luận" (TL / HĐ / VDTT) ----------
+// dau: thẻ đầu câu; loi-giai: chỉ hiện khi bật đáp án;
+// cho-trong: chừa chỗ trống khi in đề.
+#let _than-tl(dau, cau, loi-giai, cho-trong, lo-da, hinh: none,
+ fig-pos: "right", fig-width: auto,
+ hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = context block(
+ width: 100%, above: 10pt * _he-so-gian(), below: 10pt * _he-so-gian(),
+ {
+ voi-hinh({ dau; cau }, hinh, vi-tri: fig-pos, be-rong: fig-width)
+ context {
+ if _da-hien(lo-da) and loi-giai != none {
+ _khoi-giai(loi-giai, hinh: hinh-giai, vi-tri: fig-giai-pos, be-rong: fig-giai-width)
+ } else {
+ let t = _trong-tl.get()
+ if t.bat {
+ let cao = if cho-trong > 0pt { cho-trong } else { t.cao }
+ if cao > 0pt { v(cao) }
+ }
+ }
+ }
+ },
+)
+
+// Thẻ đầu câu có nhãn + bộ đếm + màu RIÊNG (HĐ, Vận dụng).
+#let _dau-rieng(nhan, bd, mau, diem) = {
+ _the([#nhan#_so-moi(bd)], mau: mau)
+ if diem != none {
+ text(weight: "bold", fill: mau, size: 0.86em)[ (#diem điểm)]
+ }
+ h(6pt)
+}
+
+// Thẻ đầu câu KHÔNG đánh số + màu RIÊNG (Khám phá, Trải nghiệm, Thảo luận —
+// dạng hoạt động một-lần của SGK, nhãn không mang số thứ tự).
+#let _dau-nhan(nhan, mau, diem) = {
+ _the([#nhan], mau: mau)
+ if diem != none {
+ text(weight: "bold", fill: mau, size: 0.86em)[ (#diem điểm)]
+ }
+ h(6pt)
+}
+
+// ---------- TL: tự luận ----------
+#let cau-tl(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-cau(diem: diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
+
+// ---------- HĐ: hoạt động (khởi động / khám phá kiến thức) ----------
+// Thẻ "HĐ1", "HĐ2"... — bộ đếm riêng, hình thức như TL (dùng được cot-item
+// trong thân câu khi có nhiều ý hỏi).
+#let _cam-hd = rgb("#d35400")
+#let cau-hd(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-rieng([HĐ], _bd-hd, _cam-hd, diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
+
+// ---------- LT: luyện tập (củng cố lý thuyết vừa học) ----------
+// Thẻ "Luyện tập 1", "Luyện tập 2"... — DÙNG CHUNG bộ đếm với khung
+// #luyen-tap (slide.typ) nên số thứ tự liên tục dù trộn hai kiểu.
+#let _vang-lt = rgb("#b9770e")
+#let cau-lt(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-rieng([Luyện tập~], _bd-lt, _vang-lt, diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
+
+// ---------- VDTT: vận dụng thực tế ----------
+// Thẻ "Vận dụng 1", "Vận dụng 2"... — bộ đếm riêng, hình thức như TL.
+#let _tim-vdtt = rgb("#8e44ad")
+#let cau-vdtt(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-rieng([Vận dụng~], _bd-vdtt, _tim-vdtt, diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
+
+// ---------- 3 HÌNH THỨC HOẠT ĐỘNG SGK — KHÔNG đánh số (Kết nối tri thức) ----
+// Nhãn "Khám phá" / "Trải nghiệm" / "Thảo luận" như thẻ HĐ nhưng KHÔNG mang số
+// thứ tự (hoạt động một-lần); hình thức thân như TL (dùng được cot-item, hình,
+// lời giải, lo-da…). Mỗi loại một màu riêng.
+#let _luc-kham-pha = rgb("#16a085") // xanh lục (khám phá)
+#let cau-kham-pha(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-nhan([Khám phá], _luc-kham-pha, diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
+
+#let _luc-trai-nghiem = rgb("#27ae60") // xanh lá (trải nghiệm)
+#let cau-trai-nghiem(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-nhan([Trải nghiệm], _luc-trai-nghiem, diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
+
+#let _lam-thao-luan = rgb("#2980b9") // xanh dương (thảo luận)
+#let cau-thao-luan(cau, loi-giai: none, diem: none, cho-trong: 0pt, hinh: none, lo-da: none,
+ fig: none, fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto) = _than-tl(
+ _dau-nhan([Thảo luận], _lam-thao-luan, diem), cau, loi-giai, cho-trong, lo-da,
+ hinh: if hinh != none { hinh } else { fig },
+ fig-pos: fig-pos, fig-width: fig-width,
+ hinh-giai: if hinh-giai != none { hinh-giai } else { fig-giai },
+ fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width,
+)
diff --git a/packages/preview/conic-toan/0.3.4/lib/che-do.typ b/packages/preview/conic-toan/0.3.4/lib/che-do.typ
new file mode 100644
index 0000000000..4d207cd50a
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/che-do.typ
@@ -0,0 +1,1394 @@
+// =====================================================================
+// che-do.typ — MỘT FILE NGUỒN, BA KIỂU PDF (hồ sơ hiển thị)
+//
+// "dethi" : dạng đề thi/sách A4 — MC, TF, SA, TL ẩn đáp án + lời giải;
+// riêng VÍ DỤ vẫn hiện lời giải.
+// "loigiai" : dạng A4 — tất cả hiện lời giải, đánh dấu đáp án.
+// "beamer" : trình chiếu — MỖI CÂU MỘT SLIDE: hiện đề → lời giải hiện
+// dần theo từng dấu \ (xuống dòng) → cuối cùng đánh dấu đáp án.
+// (Tên cũ "de-thi", "loi-giai" vẫn được chấp nhận.)
+//
+// Cách dùng (xem de-mau.typ):
+// #let ho-so = sys.inputs.at("ho-so", default: "dethi")
+// #show: de-toan.with(ho-so: ho-so, tieu-de: [...], ...)
+// rồi gọi THẲNG #vd/#tn/#ds/#tln/#tl/#hd/#lt/#vdtt/#phan — các hàm tự
+// nhận biết hồ sơ hiển thị, KHÔNG cần khai báo gì thêm.
+// (tn = trắc nghiệm, ds = đúng sai, tln = trả lời ngắn, tl = tự luận,
+// hd = hoạt động, lt = luyện tập, vdtt = vận dụng thực tế;
+// tên cũ mc/tf/sa và dòng tao-cau-hoi cũ vẫn dùng được)
+// =====================================================================
+#import "slide.typ": slide, muc, bai-giang, lo, vi-du, loi-giai, _ho-so, _la-sach, _buoc-ht, gian-dong, _gd, _dat-gian, _he-so-gian, _gian-ht, _bd-cau, cao-that, _cao-that, _chong-net, voi-cao-that, _vung-than
+#import "cau-hoi.typ": cau-mc, cau-tf, cau-sa, cau-tl, cau-hd, cau-lt, cau-vdtt, cau-kham-pha, cau-trai-nghiem, cau-thao-luan, bat-dap-an, tat-dap-an, voi-hinh, True, Dung, _la-y
+
+// Gộp tham số kiểu mới / kiểu cũ: ưu tiên giá trị kiểu mới nếu được đặt.
+#let _uu-tien(moi, cu) = if moi != none { moi } else { cu }
+
+#let _xanh = rgb("#0f4c81")
+#let _luc = rgb("#1e8449")
+#let _do = rgb("#a93226")
+// giữ tham chiếu đến môi trường lời giải (vì tham số cùng tên sẽ che nó)
+#let _mt-loi-giai = loi-giai
+
+// Mã đề khai ở de-toan -> lưu vào state để #bang-dap-an tự lấy (khỏi nhập tay).
+#let _ma-de = state("bg-ma-de", none)
+
+// =====================================================================
+// HOÁN VỊ (TRỘN ĐỀ) — công tắc đặt Ở ĐẦU FILE ĐỀ
+// ---------------------------------------------------------------------
+// Cách dùng (khung một file ngân hàng / một đề thi):
+// #let ho-so = sys.inputs.at("ho-so", default: "dethi")
+// #let hoan-vi = false // <- ĐỔI THÀNH true LÀ TRỘN
+// #show: de-toan.with(ho-so: ho-so, ma-de: "0101", hoan-vi: hoan-vi)
+//
+// NGUYÊN TẮC (chốt 08/2026):
+// • Hoán vị các CÂU trong cùng MỘT NHÓM THỂ LOẠI: nhóm tn xáo với nhau,
+// nhóm ds xáo với nhau, nhóm tln xáo với nhau. Câu tl GIỮ NGUYÊN thứ tự.
+// "Nhóm" = dãy câu CÙNG LOẠI đứng liền nhau (chỉ cách nhau bằng dòng
+// trống). Gặp #phan, một đoạn văn xen giữa, một câu tl... là hết nhóm và
+// sang nhóm mới ⇒ câu KHÔNG BAO GIỜ nhảy qua tiêu đề phần.
+// • Hoán vị 4 PHƯƠNG ÁN A/B/C/D của câu tn. Các Ý a/b/c/d của câu ds GIỮ
+// NGUYÊN, KHÔNG hoán vị (ý sau thường dựa vào ý trước).
+// • Chỉ chạy ở bản in A4 (dethi/loigiai); trình chiếu beamer không trộn.
+// • dethi và loigiai cùng mầm ⇒ RA CÙNG MỘT THỨ TỰ, bảng đáp án luôn khớp.
+//
+// hoan-vi: false (mặc định) | true (trộn cả câu lẫn phương án)
+// | "cau" (chỉ trộn thứ tự câu) | "pa" (chỉ trộn phương án)
+// mam: MÃ TRỘN — auto (mặc định) = băm từ `ma-de`, nên hai mã đề 0101 /
+// 0102 tự cho hai thứ tự khác nhau; đặt tay `mam: 7` để ghi đè.
+//
+// GIỚI HẠN đã biết:
+// • Câu tn viết theo FORM CŨ `dap-an: "B"` (chữ cái) KHÔNG được trộn phương
+// án — trộn sẽ lệch đáp án. Muốn trộn thì bọc phương án đúng bằng True(...).
+// • `khoa-pa: true` ở một câu tn ⇒ riêng câu đó giữ nguyên thứ tự phương án.
+// =====================================================================
+
+// ---------- Bộ sinh số giả ngẫu nhiên TẤT ĐỊNH (LCG 32 bit) ----------
+// Typst không có random. Dùng LCG để CÙNG một mầm luôn cho CÙNG một kết quả —
+// nhờ vậy bản dethi và bản loigiai (hai lần biên dịch khác nhau) trộn giống hệt.
+#let _hv-m = 2147483648
+#let _hv-tiep(s) = calc.rem(1103515245 * s + 12345, _hv-m)
+
+// Chuẩn hoá mầm: số / chuỗi / nội dung / none -> số nguyên không âm (băm djb2).
+#let _hv-mam(x) = {
+ if x == none or x == auto { return 20250101 }
+ if type(x) == int { return calc.rem(calc.abs(x), _hv-m) }
+ let s = if type(x) == str { x } else { repr(x) }
+ let h = 5381
+ for c in s.clusters() {
+ h = calc.rem(h * 33 + str.to-unicode(c), _hv-m)
+ }
+ h
+}
+
+// Xáo một mảng theo mầm — RÚT ngẫu nhiên không hoàn lại (khỏi gán theo chỉ số).
+#let _hv-xao(mang, mam) = {
+ let con = mang
+ let kq = ()
+ let s = _hv-tiep(_hv-mam(mam) + 1013904223)
+ while con.len() > 0 {
+ s = _hv-tiep(s)
+ let j = calc.rem(calc.quo(s, 65536), con.len())
+ kq.push(con.at(j))
+ con = con.slice(0, j) + con.slice(j + 1)
+ }
+ kq
+}
+
+// ---------- Nhãn nhận diện câu (đánh dấu chỗ được phép xáo) ----------
+#let _hv-nhan = (tn: , ds: , tln: )
+#let _gan-tn(c) = [#c ]
+#let _gan-ds(c) = [#c ]
+#let _gan-tln(c) = [#c ]
+
+// Phần tử "trắng" giữa hai câu (dòng trống, xuống dòng, khoảng trắng,
+// metadata) — bỏ qua khi gom nhóm và LUÔN giữ nguyên vị trí lúc ghép lại.
+#let _hv-f-space = [ ].func()
+#let _hv-trong(c) = {
+ if type(c) == str { return c.trim() == "" }
+ if type(c) != content { return false }
+ let f = c.func()
+ f == _hv-f-space or f == parbreak or f == linebreak or f == metadata
+}
+
+// Loại của một phần tử: "tn" | "ds" | "tln" | none.
+// Nếu markup bọc câu trong một sequence MỘT phần tử thì bóc thêm một lớp.
+#let _hv-loai(c) = {
+ if type(c) != content { return none }
+ let l = c.at("label", default: none)
+ if l == _hv-nhan.tn { return "tn" }
+ if l == _hv-nhan.ds { return "ds" }
+ if l == _hv-nhan.tln { return "tln" }
+ if l == none and c.has("children") {
+ let ct = c.children.filter(x => not _hv-trong(x))
+ if ct.len() == 1 { return _hv-loai(ct.first()) }
+ }
+ none
+}
+
+// ---------- Xáo THỨ TỰ CÂU trong thân tài liệu ----------
+// Duyệt các phần tử cấp cao nhất, gom từng DÃY LIỀN NHAU cùng loại rồi hoán vị
+// các phần tử của dãy đó TẠI CHỖ (vị trí nào là câu tn thì sau khi trộn vẫn là
+// câu tn). Phần tử không nhận diện được giữ nguyên vị trí ⇒ nếu vì lý do nào đó
+// không nhận ra câu, tài liệu vẫn dựng đúng y như khi tắt trộn.
+#let _hv-xao-than(than, mam) = {
+ if type(than) != content or not than.has("children") { return than }
+ let cs = than.children
+ let n = cs.len()
+ let kq = ()
+ let nhom = 0
+ let i = 0
+ while i < n {
+ let loai = _hv-loai(cs.at(i))
+ if loai == none {
+ kq.push(cs.at(i))
+ i = i + 1
+ } else {
+ // gom dãy: các câu CÙNG loại, chỉ được cách nhau bằng phần tử trắng
+ let vt = ()
+ let cuoi = i
+ let j = i
+ while j < n {
+ let lj = _hv-loai(cs.at(j))
+ if lj == loai {
+ vt.push(j)
+ cuoi = j
+ j = j + 1
+ } else if _hv-trong(cs.at(j)) {
+ j = j + 1
+ } else {
+ break
+ }
+ }
+ nhom = nhom + 1
+ let cac = vt.map(k => cs.at(k))
+ let moi = if cac.len() > 1 { _hv-xao(cac, _hv-mam(mam) + nhom * 7919) } else { cac }
+ let d = 0
+ for k in range(i, cuoi + 1) {
+ if vt.contains(k) {
+ kq.push(moi.at(d))
+ d = d + 1
+ } else {
+ kq.push(cs.at(k))
+ }
+ }
+ i = cuoi + 1
+ }
+ }
+ if kq.len() == 0 { than } else { kq.join() }
+}
+
+// Trạng thái để #tn biết có xáo phương án hay không (và xáo theo mầm nào).
+// bat: xáo PHƯƠNG ÁN · cau: xáo THỨ TỰ CÂU · mam: mầm chung.
+// Khoá `cau` để các lệnh bố cục (#chia-2-cot, #chia-2-cot-lech) tự xáo lại
+// phần thân NẰM TRONG cột — vì thân đó đã bị bọc thành MỘT phần tử nên vòng
+// duyệt ở #de-toan không nhìn thấy các câu bên trong.
+#let _hv = state("bg-hoan-vi", (bat: false, cau: false, mam: 0))
+
+// Tách công tắc hoan-vi thành hai cờ: xáo CÂU và xáo PHƯƠNG ÁN.
+#let _hv-che(h) = (
+ cau: h == true or h == "cau" or h == "ca-hai",
+ pa: h == true or h == "pa" or h == "ca-hai",
+)
+
+// Dùng như một show-rule RIÊNG khi tài liệu không đi qua #de-toan:
+// #show: hoan-vi-de.with(true)
+// #show: hoan-vi-de.with(true, mam: "0102")
+// (Đi qua #de-toan thì chỉ cần tham số hoan-vi: — khỏi gọi hàm này.)
+#let hoan-vi-de(bat, mam: auto, body) = {
+ let che = _hv-che(bat)
+ let m = _hv-mam(mam)
+ _hv.update((bat: che.pa, cau: che.cau, mam: m))
+ if che.cau { _hv-xao-than(body, m) } else { body }
+}
+
+// ---------- Tách lời giải thành từng dòng ----------
+// Nhận nội dung có dấu \ (hoặc mảng nội dung), trả về mảng các dòng.
+#let tach-dong(nd) = {
+ if nd == none { return () }
+ if type(nd) == array { return nd }
+ if type(nd) == content and nd.has("children") {
+ let phan = ()
+ let dong = ()
+ for c in nd.children {
+ if c.func() == linebreak or c.func() == parbreak {
+ phan.push(dong.join())
+ dong = ()
+ } else {
+ dong.push(c)
+ }
+ }
+ phan.push(dong.join())
+ return phan.filter(p => p != none)
+ }
+ (nd,)
+}
+
+// ---------- Ngắt lời giải dài sang slide mới (chỉ tác dụng ở beamer) ----------
+// Đặt #sang-man trên MỘT DÒNG RIÊNG trong lời giải:
+// loi-giai: [dòng 1 \ dòng 2 \ #sang-man \ dòng 3 \ ...]
+// => "dòng 3" trở đi chuyển sang slide kế tiếp (nhãn "Hướng dẫn giải (tiếp)"),
+// tránh lời giải tràn trang làm sinh trang đệm trắng khi hiện từng bước.
+// Ở bản A4 ("dethi"/"loigiai"), dấu này được bỏ qua.
+#let sang-man = metadata("bg-sang-man")
+
+#let _co-sang-man(d) = {
+ if type(d) != content { return false }
+ if d.func() == metadata { return d.value == "bg-sang-man" }
+ if d.has("children") { d.children.any(c => _co-sang-man(c)) } else { false }
+}
+
+#let _khoang-trang = [ ].func()
+
+// Dòng "rỗng" — chỉ gồm khoảng trắng / dấu ngắt / metadata (không in ra gì).
+#let _dong-trong(d) = {
+ if d == none { return true }
+ if type(d) != content { return false }
+ let f = d.func()
+ if f == _khoang-trang or f == linebreak or f == parbreak or f == metadata {
+ return true
+ }
+ if f == text { return d.text.trim() == "" }
+ if d.has("children") { return d.children.all(c => _dong-trong(c)) }
+ false
+}
+
+// Cắt MỘT dòng tại dấu #sang-man đầu tiên -> (trước, sau, có-cắt-được).
+// ⚠️ Lỗi cũ (09/08/2026): `tach-man` coi CẢ DÒNG chứa dấu là dấu ngắt rồi
+// VỨT ĐI. Người soạn viết `... hết ý.` xuống dòng rồi `#sang-man \` (không có
+// dấu \ ở cuối dòng chữ) thì cả câu chữ đó BIẾN MẤT khỏi trình chiếu. Nay cắt
+// đôi tại dấu, giữ nguyên phần chữ hai bên.
+// Chỉ cắt ở lớp con TRỰC TIẾP để không bóc mất lớp bọc (strong, emph...);
+// dấu nằm sâu hơn thì trả `false` và dòng được giữ nguyên (dấu vô hình, chỉ
+// mất tác dụng ngắt màn) — thà không ngắt còn hơn mất chữ.
+#let _cat-sang-man(d) = {
+ if type(d) != content { return (d, none, false) }
+ if d.func() == metadata {
+ return if d.value == "bg-sang-man" { (none, none, true) } else { (d, none, false) }
+ }
+ if not d.has("children") { return (d, none, false) }
+ let truoc = ()
+ let sau = ()
+ let co = false
+ for c in d.children {
+ if co { sau.push(c) }
+ else if (type(c) == content and c.func() == metadata
+ and c.value == "bg-sang-man") { co = true }
+ else { truoc.push(c) }
+ }
+ if not co { (d, none, false) } else { (truoc.join(), sau.join(), true) }
+}
+
+// Tách lời giải thành các màn; mỗi màn là một mảng dòng.
+#let tach-man(nd) = {
+ let man = ()
+ let ht = ()
+ for d in tach-dong(nd) {
+ let con = d
+ let dau = true // lần lặp đầu: `con` còn là NGUYÊN dòng ban đầu
+ while con != none {
+ let (truoc, sau, co) = _cat-sang-man(con)
+ // mảnh cắt ra chỉ toàn khoảng trắng thì bỏ; dòng nguyên thì giữ y như cũ
+ if (dau and not co) or not _dong-trong(truoc) { ht.push(truoc) }
+ if not co { con = none } else {
+ if ht.len() > 0 { man.push(ht) }
+ ht = ()
+ con = sau
+ }
+ dau = false
+ }
+ }
+ if ht.len() > 0 { man.push(ht) }
+ if man.len() == 0 { ((),) } else { man }
+}
+
+// Ghép lại thành một khối (dùng cho bản A4) — bỏ các dấu #sang-man nhưng
+// GIỮ nguyên chữ nằm cùng dòng với dấu (đi chung một đường với tach-man).
+#let _ghep(nd) = {
+ if nd == none { return none }
+ let dong = ()
+ for mk in tach-man(nd) { dong += mk }
+ if dong.len() == 0 { return none }
+ dong.join(linebreak())
+}
+
+// ---------- Lời giải hiện dần từng dòng (dùng trong slide) ----------
+// Khung hiện từ bước `tu`; dòng thứ i hiện ở bước tu + i.
+// gian: giãn dòng trong lời giải — ĐỘ DÀI tuyệt đối (mốc 0.95em, ~150% so với
+// thân slide 0.62em) để phân số, căn thức chồng tầng không dính vào nhau.
+// `auto` (mặc định) = 0.95em × hệ số giãn dòng hiện hành.
+// gian-dong: HỆ SỐ giãn dòng riêng cho khung này (auto = lấy hệ số toàn tài
+// liệu do #bai-giang/#de-toan/#gian-dong đặt).
+// do: true -> dựng SẴN mọi bước (không qua #lo) để ĐO chiều cao lúc cuối bài;
+// dùng cho bộ tự ngắt màn, không phải để hiển thị.
+#let giai-buoc(nd, tu: 2, nhan: [Hướng dẫn giải. ], gian: auto, gian-dong: auto,
+ do: false) = {
+ let dong = tach-dong(nd)
+ if dong.len() == 0 { return }
+ let khung = block(
+ width: 100%, inset: (left: 11pt, top: 5pt, bottom: 5pt),
+ stroke: (left: 2.5pt + _luc), above: 10pt,
+ context {
+ let k = if gian-dong == auto { _he-so-gian() } else { gian-dong }
+ let g = if gian == auto { 0.95em * k } else { gian }
+ set par(leading: g, spacing: _gd.get().at("doan", default: 1.2em) * k)
+ align(center, text(fill: _luc, weight: "bold", size: 0.84em, nhan))
+ dong.at(0)
+ for i in range(1, dong.len()) {
+ let b = block(above: g, dong.at(i))
+ if do { b } else { lo(tu + i, b) }
+ }
+ },
+ )
+ if do { khung } else { lo(tu, khung) }
+}
+
+// ---------- Ghi đè giãn dòng cho MỘT câu ----------
+// gd == auto -> giữ nguyên; ngược lại nhân hệ số gd vào leading NỀN của hồ sơ.
+#let _voi-gian(gd, nd) = if gd == auto { nd } else {
+ context {
+ let g = _gd.get()
+ // giãn CẢ khoảng cách dòng lẫn khoảng cách đoạn (dòng trống trong nội dung)
+ set par(leading: g.nen * gd, spacing: g.at("doan", default: 1.2em) * gd)
+ nd
+ }
+}
+
+// ---------- Thiết lập tài liệu theo hồ sơ ----------
+// Chuẩn hoá tên hồ sơ: "de-thi" -> "dethi", "LoiGiai" -> "loigiai"...
+#let _chuan-ho-so(hs) = lower(hs).replace("-", "").replace("_", "")
+
+#let de-toan(
+ ho-so: "dethi",
+ tieu-de: [ĐỀ KIỂM TRA],
+ tieu-de-ngan: none, // tên bài rút gọn ở header mọi slide (chỉ dùng ở beamer)
+ nen: "trang", // nền slide beamer: "trang" | "kem" | "xanh-nhat"
+ // | "luc-nhat" | "xam" | màu tuỳ ý (bản A4 luôn trắng)
+ mon: none, // vd [MÔN TOÁN 12]
+ thoi-gian: none, // vd "90 phút"
+ truong: none, // vd [SỞ GD&ĐT ... \ TRƯỜNG THPT ...]
+ ma-de: none,
+ // ----- 3 thông tin header đề thi: CHỈ hiện khi được khai báo -----
+ hien-ho-ten: false, // true -> dòng "Họ và tên thí sinh: ..."
+ sbd: none, // hiện dòng số báo danh / lớp:
+ // none | false -> ẩn
+ // true | "sbd" -> "Số báo danh: ..."
+ // "lop" -> "Lớp: ..."
+ hien-ma-de: false, // true -> ô "Mã đề ..." (dùng giá trị ma-de nếu có)
+ thong-tin-hs: true, // true: đề thi thật — có "(Đề thi có N trang)" (bản dethi)
+ // và "(Đề thi có N trang)"; false: tài liệu bài học
+ ti-le-chu: 1.0, // HỆ SỐ PHÓNG CỠ CHỮ THÂN NỘI DUNG (toàn file) —
+ // 1.0 giữ nguyên; >1 to hơn, <1 nhỏ đi. Áp dụng cho
+ // cả 3 hồ sơ (beamer + A4 dethi/loigiai).
+ gian-dong: 1.0, // HỆ SỐ GIÃN DÒNG (toàn file) — 1.0 = mốc mặc định
+ // (A4 đề thi 0.65em, trình chiếu 0.62em); 1.25 = giãn
+ // thêm 25%; 0.9 = thu lại 10%. Tăng khi phân số/căn
+ // thức nhiều tầng làm hai dòng dính nhau. Đổi giữa
+ // bài: #gian-dong(1.4); riêng một câu: tham số
+ // gian-dong: của #vd/#tn/#ds/#tln/#tl/#hd/#lt/#vdtt.
+ mau-cong-thuc: auto, // MÀU MỌI CÔNG THỨC trong $...$ (toàn file) — auto = thừa
+ // kế màu chữ (đen ở thân, trắng ở tiêu đề); đặt màu cụ
+ // thể để nhuộm tất cả. Dùng chung cho cả 3 hồ sơ.
+ hoan-vi: false, // TRỘN ĐỀ — xem khối "HOÁN VỊ" ở đầu file này:
+ // false (mặc định) = giữ nguyên thứ tự soạn;
+ // true = trộn thứ tự CÂU (theo nhóm tn/ds/tln, câu
+ // tl giữ nguyên) VÀ trộn phương án A/B/C/D
+ // của câu tn (ý a/b/c/d của ds giữ nguyên);
+ // "cau" = chỉ trộn thứ tự câu;
+ // "pa" = chỉ trộn phương án.
+ // Chỉ tác dụng ở bản in A4 (dethi/loigiai).
+ mam: auto, // MÃ TRỘN: auto = băm từ `ma-de` (mã đề khác ⇒ thứ tự
+ // khác); hoặc số/chuỗi tuỳ ý để ghi đè.
+ phu-de: none, gv: none, ngay: none,
+ body,
+) = {
+ let hs = _chuan-ho-so(ho-so)
+ _ma-de.update(ma-de) // lưu mã đề để #bang-dap-an tự đồng bộ
+ // ----- Hoán vị (trộn đề): chỉ ở bản in A4, beamer giữ nguyên bài giảng -----
+ let _che = _hv-che(hoan-vi)
+ let _mam = _hv-mam(if mam == auto { ma-de } else { mam })
+ _hv.update((bat: _che.pa and hs != "beamer", cau: _che.cau and hs != "beamer", mam: _mam))
+ let body = if _che.cau and hs != "beamer" { _hv-xao-than(body, _mam) } else { body }
+ if hs == "beamer" {
+ bai-giang(tieu-de: tieu-de, tieu-de-ngan: tieu-de-ngan, nen: nen,
+ ti-le-chu: ti-le-chu, gian-dong: gian-dong, mau-cong-thuc: mau-cong-thuc,
+ phu-de: phu-de, gv: gv, ngay: ngay, {
+ bat-dap-an()
+ body
+ })
+ } else {
+ // Ghi hồ sơ vào trạng thái để các hàm câu hỏi (vd/tn/ds/...) tự nhận
+ // biết đang ở bản A4; mọi #lo(n) trên bản in đều hiện đủ.
+ _ho-so.update("sach" + hs)
+ _buoc-ht.update(1000000)
+ set page(
+ paper: "a4",
+ margin: (x: 1.8cm, top: 1.6cm, bottom: 2.2cm),
+ footer: context align(center, text(size: 9pt, fill: luma(35%), {
+ [Trang #counter(page).display() / #counter(page).final().first()]
+ if ma-de != none { [ — Mã đề #ma-de] }
+ })),
+ )
+ set text(size: 11pt * ti-le-chu, lang: "vi")
+ show math.equation: it => {
+ if mau-cong-thuc == auto { it } else {
+ set text(fill: mau-cong-thuc)
+ it
+ }
+ }
+ // 0.65em = leading mặc định của Typst -> gian-dong: 1.0 giữ y nguyên bố cục.
+ _dat-gian(0.65em, gian-dong)
+ set par(justify: true, leading: 0.65em * gian-dong, spacing: 1.2em * gian-dong)
+ // Công thức TRONG DÒNG khai đúng chiều cao nét vẽ (phân số, căn, chỉ số
+ // chồng tầng) ⇒ dòng VÀ ô lưới phương án tự nới đúng chỗ cần, khỏi phải
+ // đôn `gian-dong` lên 3 rồi làm trống hoác chỗ khác. Tắt: #cao-that(false).
+ show math.equation.where(block: false): _chong-net
+ if hs == "loigiai" { bat-dap-an() } else { tat-dap-an() }
+ // ----- đầu đề thi -----
+ grid(
+ columns: (1fr, 1.25fr), column-gutter: 14pt,
+ align(center, {
+ if truong != none {
+ text(weight: "bold", size: 10pt, truong)
+ v(3pt)
+ }
+ // Dòng "(Đề thi có N trang)" chỉ dành cho đề thi thật —
+ // tài liệu kiểu bài học đặt thong-tin-hs: false là bỏ.
+ if thong-tin-hs {
+ text(size: 9pt, style: "italic")[(Đề thi có #context [#counter(page).final().first()] trang)]
+ }
+ }),
+ align(center, {
+ text(weight: "bold", size: 12.5pt, fill: _xanh, tieu-de)
+ if mon != none {
+ linebreak()
+ text(weight: "bold", size: 11pt, mon)
+ }
+ if thoi-gian != none {
+ linebreak()
+ text(size: 9pt, style: "italic")[Thời gian làm bài: #thoi-gian]
+ }
+ if ngay != none and hs == "dethi" {
+ linebreak()
+ text(size: 9pt, style: "italic")[Ngày kiểm tra: #ngay]
+ }
+ if hs == "loigiai" {
+ linebreak()
+ text(weight: "bold", size: 10.5pt, fill: _do)[ĐÁP ÁN VÀ LỜI GIẢI CHI TIẾT]
+ }
+ }),
+ )
+ // ----- 3 thông tin tuỳ chọn: chỉ hiện mục nào được khai báo -----
+ let hien-sbd = sbd != none and sbd != false
+ let co-trai = hien-ho-ten or hien-sbd
+ if hs == "dethi" and (co-trai or hien-ma-de) {
+ v(10pt)
+ let cot-trai = {
+ let dong = ()
+ if hien-ho-ten {
+ dong.push([Họ và tên thí sinh: #box(width: 1fr, repeat[.])])
+ }
+ if hien-sbd {
+ let nhan-sbd = if sbd == "lop" [Lớp] else [Số báo danh]
+ dong.push([#nhan-sbd: #box(width: 1fr, repeat[.])])
+ }
+ dong.join(v(4pt))
+ }
+ let o-ma-de = rect(inset: 8pt, stroke: 0.8pt,
+ text(weight: "bold")[Mã đề #if ma-de == none [......] else [#ma-de]])
+ if co-trai and hien-ma-de {
+ grid(columns: (1fr, auto), column-gutter: 12pt,
+ cot-trai, align(horizon, o-ma-de))
+ } else if co-trai {
+ cot-trai
+ } else {
+ align(right, o-ma-de)
+ }
+ }
+ v(8pt)
+ line(length: 100%, stroke: 0.7pt)
+ v(4pt)
+ body
+ }
+}
+
+// ---------- Bộ hàm câu hỏi theo hồ sơ ----------
+// Trả về (vd, tn, ds, tln, tl, phan) có CÙNG cách gọi ở cả 3 hồ sơ
+// (kèm bí danh cũ mc/tf/sa để tương thích ngược).
+// Trong lời giải, mỗi dấu \ là một bước xuất hiện (chỉ tác dụng ở beamer).
+// tieu-de: tiêu đề slide của câu (chỉ dùng ở beamer).
+// Các slide "(tiếp)" cho những màn lời giải sau màn đầu.
+// nhan-dau: nhãn của màn ĐẦU TIÊN khi màn 0 rỗng (đề dài quá nên lời giải bị
+// đẩy hết sang đây) — lúc đó chưa có gì để "tiếp", ghi nhãn thường.
+#let _man-tiep(man, tieu-de, nhan: [Hướng dẫn giải (tiếp). ],
+ nhan-dau: [Hướng dẫn giải. ], gd: auto) = {
+ for (i, mk) in man.slice(1).enumerate() {
+ let nh = if i == 0 and man.at(0).len() == 0 { nhan-dau } else { nhan }
+ slide(tieu-de: tieu-de, so-buoc: calc.max(1, mk.len()))[
+ #_voi-gian(gd, giai-buoc(mk, tu: 1, nhan: nh, gian-dong: gd))
+ ]
+ }
+}
+
+// ---------- 8 DẠNG CÂU DÙNG TRỰC TIẾP Ở MỌI CHẾ ĐỘ ----------
+// Các hàm #vd/#tn/#ds/#tln/#tl/#hd/#lt/#vdtt/#phan tự đọc hồ sơ hiển thị
+// (do bai-giang/de-toan ghi vào trạng thái) rồi chọn cách dựng:
+// beamer -> mỗi câu một slide, lời giải hiện dần theo từng dấu \;
+// A4 -> khối liên tục (dethi ẩn/loigiai hiện đáp án).
+// KHÔNG cần khai báo #let (...) = tao-cau-hoi(...) nữa — import là dùng.
+// Trình tự beamer: bước 1 hiện đề — bước 2.. lời giải từng dòng — đánh dấu
+// đáp án ở BƯỚC CUỐI màn đầu; các màn #sang-man thành slide "(tiếp)".
+
+// Hình kèm LỜI GIẢI (mọi dạng câu): fig-giai (bí danh hinh-giai) +
+// fig-giai-pos/fig-giai-width — bố cục 2 cột như hình kèm đề; ở beamer
+// hình hiện từ bước đầu tiên của lời giải, các màn #sang-man sau muốn có
+// hình thì chèn trực tiếp trong nội dung màn đó.
+#let _giai-kem-hinh(nd, hg, vi-tri, be-rong, do: false) = voi-hinh(
+ nd,
+ if hg == none { none } else if do { hg } else { lo(2, hg) },
+ vi-tri: vi-tri, be-rong: be-rong,
+)
+
+// ---------- TỰ NGẮT MÀN KHI LỜI GIẢI TRÀN TRANG (chỉ beamer) ----------
+// Bệnh cũ: lời giải cao hơn thân slide thì Typst đẩy phần thừa sang TRANG SAU,
+// mà mỗi bước hoạt hình lại in slide LẠI TỪ ĐẦU ⇒ bấm chuyển bước thì thấy
+// xen kẽ "đề bài + mấy dòng đầu" — "phần tràn" — "đề bài + mấy dòng đầu" …
+// tức ĐỀ BÀI HIỆN LẶP LẠI giữa lời giải. Nay đo trước: dòng nào làm tràn thì
+// tự đẩy sang một màn "(tiếp)" y như đặt #sang-man bằng tay.
+// Tắt (giữ nếp cũ) cho cả tài liệu: #tu-ngat-man(false).
+#let _tu-man = state("bg-tu-man", true)
+#let tu-ngat-man(bat) = _tu-man.update(bat)
+
+// Cắt một màn thành nhiều màn vừa trang. `dung(dòng, có-đề)` dựng thử nội dung
+// slide (đã hiện hết mọi bước) để đo. Tìm nhị phân số dòng lớn nhất còn vừa.
+#let _cat-vua(dong, co-de, dung, vung) = {
+ if dong.len() == 0 { return (dong,) }
+ let vua(n) = measure(block(width: vung.rong, dung(dong.slice(0, n), co-de))).height <= vung.cao
+ if vua(dong.len()) { return (dong,) }
+ // Đề bài dài, chiếm gần hết slide: slide đầu CHỈ hiện đề, lời giải sang màn
+ // "(tiếp)" — thà một slide đề trơn còn hơn đề bị in lại giữa lời giải.
+ if co-de and not vua(1) { return ((),) + _cat-vua(dong, false, dung, vung) }
+ let a = 1 // màn tiếp luôn giữ ít nhất 1 dòng ⇒ chắc chắn tiến
+ let b = dong.len() // đã biết là KHÔNG vừa
+ while b - a > 1 {
+ let m = int((a + b) / 2)
+ if vua(m) { a = m } else { b = m }
+ }
+ (dong.slice(0, a),) + _cat-vua(dong.slice(a), false, dung, vung)
+}
+
+#let _cat-man-vua(man, dung) = {
+ if not _tu-man.get() { return man }
+ let vung = _vung-than()
+ let kq = ()
+ for (i, mk) in man.enumerate() {
+ if mk.len() == 0 { kq.push(mk) } else { kq += _cat-vua(mk, i == 0, dung, vung) }
+ }
+ kq
+}
+
+#let vd(noi-dung, loi-giai: none, loigiai: none, diem: none, hinh: none, fig: none,
+ fig-pos: "right", fig-width: auto,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto,
+ gian-dong: auto, tieu-de: [Ví dụ]) = context {
+ let loi-giai = _uu-tien(loigiai, loi-giai)
+ let hinh = _uu-tien(fig, hinh)
+ let hg = _uu-tien(fig-giai, hinh-giai)
+ let gd = gian-dong
+ if _la-sach(_ho-so.get()) {
+ // Ví dụ: LUÔN hiện lời giải, kể cả trong hồ sơ "dethi".
+ _voi-gian(gd, {
+ vi-du(voi-hinh(noi-dung, hinh, vi-tri: fig-pos, be-rong: fig-width))
+ if loi-giai != none {
+ _mt-loi-giai(voi-hinh(_ghep(loi-giai), hg, vi-tri: fig-giai-pos, be-rong: fig-giai-width))
+ }
+ })
+ } else {
+ let dung(dg, co-de) = if co-de {
+ _voi-gian(gd, [
+ #vi-du(voi-hinh(noi-dung, hinh, vi-tri: fig-pos, be-rong: fig-width))
+ #_giai-kem-hinh(giai-buoc(dg, nhan: [Lời giải. ], gian-dong: gd, do: true), hg, fig-giai-pos, fig-giai-width, do: true)
+ ])
+ } else {
+ _voi-gian(gd, giai-buoc(dg, tu: 1, nhan: [Lời giải (tiếp). ],
+ gian-dong: gd, do: true))
+ }
+ let man = _cat-man-vua(tach-man(loi-giai), dung)
+ // màn đầu rỗng (đề dài, lời giải đã bị đẩy hết sang màn "(tiếp)") thì
+ // slide này chỉ cần MỘT bước — đừng in lại y hệt lần nữa.
+ slide(tieu-de: tieu-de, so-buoc: if man.at(0).len() > 0 { 1 + man.at(0).len() }
+ else if man.len() > 1 { 1 } else { 2 })[
+ #_voi-gian(gd, [
+ #vi-du(voi-hinh(noi-dung, hinh, vi-tri: fig-pos, be-rong: fig-width))
+ #_giai-kem-hinh(giai-buoc(man.at(0), nhan: [Lời giải. ], gian-dong: gd), hg, fig-giai-pos, fig-giai-width)
+ ])
+ ]
+ _man-tiep(man, tieu-de, nhan: [Lời giải (tiếp). ], nhan-dau: [Lời giải. ], gd: gd)
+ }
+}
+
+// FORM MỚI (khuyến nghị — dễ hoán vị đáp án, xáo trộn tạo mã đề):
+// #tn(
+// [Nội dung đề bài],
+// ( [$A$], True([$B$]), [$C$], [$D$] ), // đáp án đúng bọc True(...)
+// loigiai: [Lời giải chi tiết],
+// fig: none, fig-pos: "right", fig-width: 35%, // hình kèm đề
+// // fig-pos: "right"|"left"|"center"|"top"|"bottom"
+// // ("center" = canh giữa, nằm giữa phần đề và các phương án)
+// cols: 0, // 0 = tự chọn số cột; 1/2/4 = cố định
+// lines: 0, // số dòng chừa làm bài (bản in đề)
+// num: auto, prefix: "Câu", boxed: false,
+// )
+// Form cũ (dap-an: "B", hinh:, cot:, loi-giai:) vẫn dùng được.
+#let tn(
+ cau, phuong-an, dap-an: none, cot: auto, cols: 0, diem: none,
+ hinh: none, fig: none, fig-pos: "right", fig-width: auto,
+ loi-giai: none, loigiai: none, lines: 0, num: auto, prefix: "Câu",
+ boxed: false, fig-giai: none, hinh-giai: none, cham: auto, khoa-pa: false,
+ fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto,
+ trong-dong: auto,
+ tieu-de: [Trắc nghiệm],
+) = _gan-tn(context {
+ let lg = _uu-tien(loigiai, loi-giai)
+ let hinh = _uu-tien(fig, hinh)
+ let hg = _uu-tien(fig-giai, hinh-giai)
+ let gd = gian-dong
+ let cot = if cols != 0 { cols } else { cot }
+ // ----- HOÁN VỊ PHƯƠNG ÁN A/B/C/D -----
+ // Chỉ xáo khi phương án đúng được bọc True(...) (form mới) — form cũ
+ // `dap-an: "B"` gắn đáp án vào CHỮ CÁI nên xáo sẽ lệch. `khoa-pa: true`
+ // giữ nguyên riêng câu này. Mầm riêng từng câu = mầm chung + số thứ tự câu
+ // ⇒ mỗi câu một hoán vị khác nhau, mà vẫn tất định.
+ let _hvt = _hv.get()
+ let phuong-an = if (_hvt.bat and not khoa-pa and phuong-an.len() > 1
+ and phuong-an.any(_la-y)) {
+ let _so = _bd-cau.goc.get() + _bd-cau.cnt.get().first()
+ _hv-xao(phuong-an, _hvt.mam + (_so + 1) * 104729)
+ } else { phuong-an }
+ let goi(..them) = cau-mc(
+ cau, phuong-an, dap-an: dap-an, cot: cot, diem: diem, hinh: hinh, cham: cham,
+ khoa-pa: khoa-pa, trong-dong: trong-dong,
+ fig-pos: fig-pos, fig-width: fig-width, lines: lines,
+ num: num, prefix: prefix, boxed: boxed,
+ hinh-giai: hg, fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width, ..them,
+ )
+ if _la-sach(_ho-so.get()) {
+ _voi-gian(gd, goi(loi-giai: _ghep(lg)))
+ } else {
+ let dung(dg, co-de) = if co-de {
+ _voi-gian(gd, [
+ #goi(lo-da: 1)
+ #_giai-kem-hinh(giai-buoc(dg, gian-dong: gd, do: true), hg, fig-giai-pos, fig-giai-width, do: true)
+ ])
+ } else {
+ _voi-gian(gd, giai-buoc(dg, tu: 1, nhan: [Hướng dẫn giải (tiếp). ],
+ gian-dong: gd, do: true))
+ }
+ let man = _cat-man-vua(tach-man(lg), dung)
+ let buoc-da = 2 + man.at(0).len()
+ slide(tieu-de: tieu-de, so-buoc: buoc-da)[
+ #_voi-gian(gd, [
+ #goi(lo-da: buoc-da)
+ #_giai-kem-hinh(giai-buoc(man.at(0), gian-dong: gd), hg, fig-giai-pos, fig-giai-width)
+ ])
+ ]
+ _man-tiep(man, tieu-de, gd: gd)
+ }
+})
+
+// FORM MỚI:
+// #ds(
+// [Thân câu chung — nêu tình huống / bài toán],
+// (
+// [Phát biểu a — SAI],
+// True([Phát biểu b — ĐÚNG]), // ý đúng bọc True(...)
+// True([Phát biểu c — ĐÚNG]),
+// [Phát biểu d — SAI],
+// ),
+// loigiai: [Phân tích từng ý.],
+// fig: none, fig-pos: "right", fig-width: 30%,
+// lines: 0, num: auto, prefix: "Câu",
+// )
+// Form cũ (dap-an: (false, true, true, false)) vẫn dùng được.
+// Lời giải riêng từng ý (an toàn khi trộn hoán vị ý) — đặt `giai:` trong
+// True(...)/Sai(...); `khoa-y: true` báo công cụ trộn đừng xáo các ý của câu.
+#let ds(
+ cau, cac-y, dap-an: none, diem: none,
+ hinh: none, fig: none, fig-pos: "right", fig-width: auto,
+ loi-giai: none, loigiai: none, o-tick: false, lines: 0, num: auto, cham: auto,
+ khoa-y: false, trong-dong: auto,
+ prefix: "Câu", boxed: false, fig-giai: none, hinh-giai: none,
+ fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto,
+ tieu-de: [Đúng — Sai],
+) = _gan-ds(context {
+ let lg = _uu-tien(loigiai, loi-giai)
+ let hinh = _uu-tien(fig, hinh)
+ let hg = _uu-tien(fig-giai, hinh-giai)
+ let gd = gian-dong
+ let goi(..them) = cau-tf(
+ cau, cac-y, dap-an: dap-an, diem: diem, hinh: hinh, o-tick: o-tick, cham: cham,
+ khoa-y: khoa-y, trong-dong: trong-dong,
+ fig-pos: fig-pos, fig-width: fig-width, lines: lines,
+ num: num, prefix: prefix, boxed: boxed,
+ hinh-giai: hg, fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width, ..them,
+ )
+ if _la-sach(_ho-so.get()) {
+ _voi-gian(gd, goi(loi-giai: _ghep(lg)))
+ } else {
+ let dung(dg, co-de) = if co-de {
+ _voi-gian(gd, [
+ #goi(lo-da: 1)
+ #_giai-kem-hinh(giai-buoc(dg, gian-dong: gd, do: true), hg, fig-giai-pos, fig-giai-width, do: true)
+ ])
+ } else {
+ _voi-gian(gd, giai-buoc(dg, tu: 1, nhan: [Hướng dẫn giải (tiếp). ],
+ gian-dong: gd, do: true))
+ }
+ let man = _cat-man-vua(tach-man(lg), dung)
+ let buoc-da = 2 + man.at(0).len()
+ slide(tieu-de: tieu-de, so-buoc: buoc-da)[
+ #_voi-gian(gd, [
+ #goi(lo-da: buoc-da)
+ #_giai-kem-hinh(giai-buoc(man.at(0), gian-dong: gd), hg, fig-giai-pos, fig-giai-width)
+ ])
+ ]
+ _man-tiep(man, tieu-de, gd: gd)
+ }
+})
+
+// FORM MỚI — đáp án đặt NGAY SAU đề bài (positional thứ 2):
+// #tln(
+// [Nội dung đề bài],
+// [$6$], // đáp án
+// loigiai: [Lời giải.],
+// fig: none, fig-pos: "right", fig-width: 30%,
+// show-boxes: true, box-count: 4, // ô điền trên phiếu (bản in đề)
+// lines: 0, num: auto, prefix: "Câu",
+// )
+// Form cũ (dap-an: [$6$]) vẫn dùng được.
+#let tln(
+ cau, ..tra-loi, dap-an: none, diem: none,
+ hinh: none, fig: none, fig-pos: "right", fig-width: auto,
+ loi-giai: none, loigiai: none, show-boxes: true, box-count: 4,
+ lines: 0, num: auto, prefix: "Câu", boxed: false,
+ fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto,
+ gian-dong: auto, tieu-de: [Trả lời ngắn],
+) = _gan-tln(context {
+ let lg = _uu-tien(loigiai, loi-giai)
+ let hinh = _uu-tien(fig, hinh)
+ let hg = _uu-tien(fig-giai, hinh-giai)
+ let gd = gian-dong
+ let dap-an = if tra-loi.pos().len() > 0 { tra-loi.pos().first() } else { dap-an }
+ let goi(..them) = cau-sa(
+ cau, dap-an: dap-an, diem: diem, hinh: hinh,
+ fig-pos: fig-pos, fig-width: fig-width,
+ show-boxes: show-boxes, box-count: box-count, lines: lines,
+ num: num, prefix: prefix, boxed: boxed,
+ hinh-giai: hg, fig-giai-pos: fig-giai-pos, fig-giai-width: fig-giai-width, ..them,
+ )
+ if _la-sach(_ho-so.get()) {
+ _voi-gian(gd, goi(loi-giai: _ghep(lg)))
+ } else {
+ let dung(dg, co-de) = if co-de {
+ _voi-gian(gd, [
+ #goi(lo-da: 1)
+ #_giai-kem-hinh(giai-buoc(dg, gian-dong: gd, do: true), hg, fig-giai-pos, fig-giai-width, do: true)
+ ])
+ } else {
+ _voi-gian(gd, giai-buoc(dg, tu: 1, nhan: [Hướng dẫn giải (tiếp). ],
+ gian-dong: gd, do: true))
+ }
+ let man = _cat-man-vua(tach-man(lg), dung)
+ let buoc-da = 2 + man.at(0).len()
+ slide(tieu-de: tieu-de, so-buoc: buoc-da)[
+ #_voi-gian(gd, [
+ #goi(lo-da: buoc-da)
+ #_giai-kem-hinh(giai-buoc(man.at(0), gian-dong: gd), hg, fig-giai-pos, fig-giai-width)
+ ])
+ ]
+ _man-tiep(man, tieu-de, gd: gd)
+ }
+})
+
+// Bộ dựng chung cho 4 dạng "kiểu tự luận" (TL/HĐ/LT/VDTT).
+#let _dang-tl(ham-cau, cau, loi-giai, diem, cho-trong, hinh, tieu-de,
+ h-pos: "right", h-width: auto,
+ hg: none, hg-pos: "right", hg-width: auto, gd: auto) = context {
+ if _la-sach(_ho-so.get()) {
+ _voi-gian(gd, ham-cau(cau, loi-giai: _ghep(loi-giai), diem: diem,
+ cho-trong: cho-trong, hinh: hinh, fig-pos: h-pos, fig-width: h-width,
+ hinh-giai: hg, fig-giai-pos: hg-pos, fig-giai-width: hg-width))
+ } else {
+ let dung(dg, co-de) = if co-de {
+ _voi-gian(gd, [
+ #ham-cau(cau, diem: diem, hinh: hinh, fig-pos: h-pos, fig-width: h-width)
+ #_giai-kem-hinh(giai-buoc(dg, gian-dong: gd, do: true), hg, hg-pos, hg-width, do: true)
+ ])
+ } else {
+ _voi-gian(gd, giai-buoc(dg, tu: 1, nhan: [Hướng dẫn giải (tiếp). ],
+ gian-dong: gd, do: true))
+ }
+ let man = _cat-man-vua(tach-man(loi-giai), dung)
+ // màn đầu rỗng (đề dài, lời giải đã bị đẩy hết sang màn "(tiếp)") thì
+ // slide này chỉ cần MỘT bước — đừng in lại y hệt lần nữa.
+ slide(tieu-de: tieu-de, so-buoc: if man.at(0).len() > 0 { 1 + man.at(0).len() }
+ else if man.len() > 1 { 1 } else { 2 })[
+ #_voi-gian(gd, [
+ #ham-cau(cau, diem: diem, hinh: hinh, fig-pos: h-pos, fig-width: h-width)
+ #_giai-kem-hinh(giai-buoc(man.at(0), gian-dong: gd), hg, hg-pos, hg-width)
+ ])
+ ]
+ _man-tiep(man, tieu-de, gd: gd)
+ }
+}
+
+// (loigiai:/fig:/fig-giai: là bí danh kiểu mới của loi-giai:/hinh:/hinh-giai:)
+#let tl(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Tự luận]) = _dang-tl(cau-tl, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+#let hd(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Hoạt động]) = _dang-tl(cau-hd, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+#let lt(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Luyện tập]) = _dang-tl(cau-lt, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+#let vdtt(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Vận dụng]) = _dang-tl(cau-vdtt, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+// 3 hình thức hoạt động SGK — KHÔNG đánh số (Khám phá / Trải nghiệm / Thảo luận)
+#let kham-pha(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Khám phá]) = _dang-tl(cau-kham-pha, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+#let trai-nghiem(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Trải nghiệm]) = _dang-tl(cau-trai-nghiem, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+#let thao-luan(cau, loi-giai: none, loigiai: none, diem: none, cho-trong: 0pt, hinh: none, fig: none, fig-pos: "right", fig-width: auto, fig-giai: none, hinh-giai: none, fig-giai-pos: "right", fig-giai-width: auto, gian-dong: auto, tieu-de: [Thảo luận]) = _dang-tl(cau-thao-luan, cau, _uu-tien(loigiai, loi-giai), diem, cho-trong, _uu-tien(fig, hinh), tieu-de, h-pos: fig-pos, h-width: fig-width, hg: _uu-tien(fig-giai, hinh-giai), hg-pos: fig-giai-pos, hg-width: fig-giai-width, gd: gian-dong)
+
+#let phan(ten, ngan: none) = context {
+ if _la-sach(_ho-so.get()) {
+ // Heading cấp 1 (để vào MỤC LỤC #outline + bookmark PDF), nhưng hiển thị
+ // y hệt khối màu cũ qua show-rule cục bộ (không ảnh hưởng heading khác).
+ show heading.where(level: 1): it => block(
+ width: 100%, fill: rgb("#eaf2f8"), inset: (x: 10pt, y: 7pt),
+ stroke: (left: 3pt + _xanh), above: 14pt, below: 10pt,
+ text(weight: "bold", fill: _xanh, it.body),
+ )
+ heading(level: 1, ten)
+ } else {
+ muc(ten, ngan: ngan)
+ }
+}
+
+// =====================================================================
+// BỐ CỤC PHẦN CÂU HỎI — #chia-2-cot · #chia-2-cot-lech (08/2026)
+// ---------------------------------------------------------------------
+// Hai lệnh này dùng như SHOW-RULE: ĐẶT Ở ĐÂU THÌ ÁP DỤNG TỪ DÒNG ĐÓ TRỞ
+// XUỐNG (đúng lối #show: de-toan.with(...) đã quen):
+//
+// #show: de-toan.with(ho-so: ho-so, tieu-de: [...])
+// #show: chia-2-cot // từ đây trở xuống: 2 cột đều nhau
+// #tn(...) #tn(...) ...
+//
+// #show: chia-2-cot-lech // trái = câu hỏi, phải = chỗ làm bài
+// #show: chia-2-cot-lech.with(rong-trai: 60%) // đổi bề rộng cột trái
+//
+// Muốn NGỪNG chia cột (bắt buộc trước #bang-dap-an, vì lệnh đó có
+// #pagebreak mà pagebreak không chạy được bên trong cột) thì đặt
+// #thoi-cot()
+// — phần sau dấu này trở lại nguyên khổ giấy.
+//
+// Cũng dùng được dạng KHỐI cho một đoạn: #chia-2-cot[ ... ].
+//
+// Chỉ tác dụng ở bản in A4 (dethi/loigiai); trình chiếu beamer bỏ qua
+// (mỗi câu vốn đã là một slide riêng).
+// =====================================================================
+
+// ---------- Dấu kết thúc vùng chia cột ----------
+#let thoi-cot() = metadata("bg-thoi-cot")
+
+#let _la-thoi-cot(c) = (type(c) == content and c.func() == metadata
+ and c.value == "bg-thoi-cot")
+
+// Tách thân thành (phần NẰM TRONG cột, phần SAU #thoi-cot()).
+// Không có dấu #thoi-cot() ⇒ (toàn bộ thân, none).
+#let _tach-thoi-cot(than) = {
+ if type(than) != content or not than.has("children") { return (than, none) }
+ let cs = than.children
+ let i = cs.position(_la-thoi-cot)
+ if i == none { return (than, none) }
+ let truoc = cs.slice(0, i)
+ let sau = cs.slice(i + 1)
+ (
+ if truoc.len() == 0 { none } else { truoc.join() },
+ if sau.len() == 0 { none } else { sau.join() },
+ )
+}
+
+// Phần thân nằm TRONG cột đã bị bọc thành MỘT phần tử nên vòng duyệt hoán vị
+// ở #de-toan không nhìn thấy các câu bên trong ⇒ xáo lại ngay tại đây.
+// (Mầm vẫn là mầm chung ⇒ bản dethi và bản loigiai trộn giống hệt nhau.)
+#let _cot-than(nd) = {
+ let hv = _hv.get()
+ if nd != none and hv.at("cau", default: false) { _hv-xao-than(nd, hv.mam) } else { nd }
+}
+
+// ---------- Chỗ trống còn lại của trang & chiều cao trọn một cột ----------
+// Trả về (chỗ còn lại của TRANG HIỆN TẠI, chiều cao MỘT CỘT đầy trang).
+// Phải gọi trong `context` (dùng here().position()).
+#let _cao-vung() = {
+ let ch = page.height
+ if type(ch) != length { return (none, none) }
+ let m = page.margin
+ // ⚠️ `page.margin` trả về lề đã CHUẨN HOÁ thành `relative` (vd `0% + 45.35pt`)
+ // chứ KHÔNG phải `length` như lúc khai — cứ đòi đúng `length` là hỏng hết.
+ let _dai(v) = {
+ if type(v) == length { v }
+ else if type(v) == relative { ch * v.ratio + v.length }
+ else if type(v) == ratio { ch * v }
+ else { none }
+ }
+ let _le(khoa, mac-dinh) = {
+ if type(m) == dictionary { _dai(m.at(khoa, default: m.at("y", default: mac-dinh)))
+ } else if m == auto { 2.5cm } else { _dai(m) }
+ }
+ let lt = _le("top", 1.6cm)
+ let ld = _le("bottom", 2.2cm)
+ if lt == none or ld == none { return (none, none) }
+ let cot = ch - lt - ld
+ if cot <= 0pt { return (none, none) }
+ (calc.max(0pt, ch - here().position().y - ld), cot)
+}
+
+// Thân gõ bằng `#for` / `#include` thường gói TẤT CẢ câu hỏi vào MỘT phần tử
+// (dãy con) ⇒ không có mối nào để chèn #colbreak(). Trải phẳng các dãy con lồng
+// nhau để lấy đúng từng câu; ghép lại thì y hệt thân cũ.
+#let _f-day = ([a] + [b]).func()
+#let _cot-la(c) = {
+ if type(c) == content and c.func() == _f-day and c.has("children") {
+ c.children.map(_cot-la).flatten()
+ } else { (c,) }
+}
+
+// ---------- Tự CÂN BẰNG chiều cao các cột ----------
+// Typst KHÔNG cân bằng `columns`: cột 1 được rót đầy tới HẾT chiều cao rồi mới
+// tràn sang cột 2 ⇒ thân ngắn (chưa đầy một trang) nằm gọn trong cột 1, cột 2
+// bỏ trắng — nhìn y như lệnh không chạy. Thân DÀI thì bệnh này rơi xuống TRANG
+// CUỐI: cột trái chạy hết trang, cột phải trắng trơn — tốn hẳn nửa tờ giấy.
+//
+// Cách chữa: các trang PHÍA TRƯỚC cứ để Typst rót tự nhiên — rót tự nhiên vốn
+// đã chặt nhất, nó chẻ được cả một câu dài làm đôi, mình tính tay chỉ thua —
+// chỉ chia đều phần nằm trên TRANG CUỐI bằng #colbreak().
+//
+// ⚠️ HAI LỐI ĐÃ THỬ VÀ BỎ, ĐỪNG QUAY LẠI:
+// 1. Xếp tay cho ĐẦY từng cột (kiểu multicol của LaTeX). Mình không chẻ được
+// một câu làm đôi như Typst nên cột nào cũng hụt một khoảng; bản `loigiai`
+// của de-mau-da-dang PHÌNH THÊM một trang (11 → 12).
+// 2. Gài `metadata` trước mỗi câu rồi `query` lại số trang cho biết chắc câu
+// nào rơi xuống trang cuối. Đúng thì có đúng, nhưng mỗi vùng chia cột lại
+// bắt Typst dựng thêm một lượt; đề có 3 vùng là vượt hạn 5 lượt, hiện
+// "document did not converge" và dòng "(Đề thi có N trang)" ở đầu đề kẹt
+// lại số cũ. Đã đối chiếu ảnh từng trang: lối ĐO dưới đây cho bố cục GIỐNG
+// HỆT lối query trên toàn bộ file thử, nên không đáng đánh đổi.
+//
+// Phép đo sai thì sai theo hướng "đoán trang cuối bắt đầu MUỘN hơn thực tế"
+// (dòng chảy thật còn có khe hở ở chỗ khối không chẻ được), tức cột trái đầy
+// hơn cột phải — vẫn đẹp chán, không bao giờ đội thêm trang.
+// cao-dau : chỗ trống còn lại của trang hiện tại (thân ngắn nằm gọn ở đây)
+// cao-cot : chiều cao một cột đầy trang
+#let _can-cot(nd, so, wcol, cao-dau, cao-cot) = {
+ if so < 2 or type(nd) != content or not nd.has("children") { return nd }
+ if type(cao-cot) != length or cao-cot <= 0pt { return nd }
+ // Thân gõ bằng #for gói tất cả vào MỘT phần tử ⇒ phải trải phẳng mới có mối
+ // để chèn #colbreak().
+ let cs = nd.children.map(_cot-la).flatten()
+ if cs.len() < 2 { return nd }
+
+ // ----- đo chiều cao từng câu ở bề rộng MỘT CỘT (phần tử trắng = 0) -----
+ let cao = cs.map(c => if _hv-trong(c) { 0pt } else {
+ measure(block(width: wcol, c)).height })
+ let tong-le = cao.fold(0pt, (a, b) => a + b)
+ if tong-le <= 0pt { return nd }
+ // Các phép đo RỜI không tính khoảng cách giữa hai khối ⇒ tổng của chúng nhỏ
+ // hơn chiều cao thật. Đo thêm cả thân rồi rải phần chênh lệch đều cho mọi câu.
+ let tong-that = measure(block(width: wcol, nd)).height
+ let he-so = if tong-that > tong-le { tong-that / tong-le } else { 1.0 }
+ let cao = cao.map(h => h * he-so)
+ let tong = tong-le * he-so
+
+ let an = 5mm // chừa chống tràn
+ let hd = if type(cao-dau) == length and cao-dau > cao-cot * 0.25 {
+ calc.min(cao-dau, cao-cot) } else { cao-cot }
+
+ // Chiều cao cộng dồn TRƯỚC mỗi câu (tich.at(i)); chốt cuối = tổng cả thân.
+ let tich = ()
+ let cong = 0pt
+ for h in cao {
+ tich.push(cong)
+ cong = cong + h
+ }
+ tich.push(cong)
+
+ // ----- câu MỞ ĐẦU trang cuối -----
+ // Trang hiện tại chứa `so` cột cao `hd`, mỗi trang sau `so` cột cao `cao-cot`.
+ let mo = 0
+ if tong > (hd - an) * so {
+ let suc1 = (hd - an) * so
+ let sucn = (cao-cot - an) * so
+ let so-trang-sau = calc.max(0, calc.ceil((tong - suc1) / sucn) - 1)
+ let h0 = suc1 + sucn * so-trang-sau
+ let k = range(cs.len()).position(i => tich.at(i) >= h0)
+ mo = if k == none { 0 } else { k }
+ }
+
+ // ----- chia ĐỀU phần nằm trên trang cuối cho `so` cột -----
+ let du = tich.at(cs.len()) - tich.at(mo)
+ if du <= 0pt { return nd }
+ let muc = du / so
+ // Trang cuối vốn đã gần đầy thì ĐỪNG đụng vào: chia ra chẳng tiết kiệm được
+ // bao nhiêu, mà cột chót chỉ cần phình hơn phép đo một chút là tràn sang
+ // trang mới — mất không một trang giấy. Chỉ can thiệp khi còn trống hẳn 15%.
+ if muc > (cao-cot - an) * 0.85 { return nd }
+
+ // Chỉ được cắt ở ĐẦU một câu THẬT (câu có chiều cao). Trang cuối không đủ
+ // `so` câu thì chia kiểu gì cũng có cột rỗng ⇒ để nguyên.
+ let that = range(mo, cs.len()).filter(i => cao.at(i) > 0pt)
+ if that.len() < so { return nd }
+ // Chọn mối GẦN mốc k·muc nhất — chứ KHÔNG phải mối đầu tiên vượt mốc: trang
+ // cuối thường chỉ còn vài câu, cắt kiểu "vượt mốc mới cắt" là hụt luôn
+ // (ba câu 100/120/228 thì chẳng mối nào vượt 224 ⇒ không cắt được chỗ nào).
+ let moi = that.slice(1)
+ let cat = ()
+ let tu = mo + 1
+ for k in range(1, so) {
+ let dich = tich.at(mo) + muc * k
+ let tot = none
+ let lech = none
+ for i in moi {
+ if i < tu { continue }
+ let d = tich.at(i) - dich
+ let ad = if d < 0pt { -d } else { d }
+ if lech == none or ad < lech { lech = ad; tot = i }
+ }
+ if tot != none { cat.push(tot); tu = tot + 1 }
+ }
+ if cat.len() == 0 { return nd }
+ // Cắt xong mà có cột vẫn quá sức chứa thì bỏ, để Typst tự rót.
+ let bien = (mo,) + cat + (cs.len(),)
+ let cao-moi = range(bien.len() - 1).map(k =>
+ tich.at(bien.at(k + 1)) - tich.at(bien.at(k)))
+ if cao-moi.fold(0pt, calc.max) > cao-cot - an { return nd }
+
+ // ----- ghép lại, chèn #colbreak() ở các mối đã chọn -----
+ let kq = ()
+ for (i, c) in cs.enumerate() {
+ if cat.contains(i) { kq.push(colbreak()) }
+ kq.push(c)
+ }
+ kq.join()
+}
+
+// ---------- #chia-2-cot — chia phần câu hỏi thành 2 cột đều nhau ----------
+// so : số cột (mặc định 2; đặt 3 nếu muốn ba cột)
+// khoang : khe giữa hai cột
+// can : true (mặc định) = tự cân chiều cao hai cột; false = để Typst rót
+// đầy cột 1 trước (lối gốc của Typst)
+#let chia-2-cot(so: 2, khoang: 18pt, can: true, body) = context {
+ if not _la-sach(_ho-so.get()) { body } else {
+ let (nd0, sau) = _tach-thoi-cot(body)
+ if nd0 != none {
+ let nd = _cot-than(nd0)
+ let (cao-dau, cao-cot) = _cao-vung()
+ layout(kich => {
+ let wcol = (kich.width - khoang * (so - 1)) / so
+ let hc = if cao-cot == none { kich.height } else { cao-cot }
+ let than = if can { _can-cot(nd, so, wcol, cao-dau, hc) } else { nd }
+ columns(so, gutter: khoang, than)
+ })
+ }
+ if sau != none { sau }
+ }
+}
+
+// ---------- #chia-2-cot-lech — cột trái câu hỏi, cột phải cho HS làm bài ----
+// Cột phải để trống, kẻ hàng ngang MỜ (kiểu vở kẻ ngang) và có vạch dọc mờ
+// ngăn giữa hai cột. Số hàng kẻ tính theo chiều cao thật của phần câu hỏi
+// (đo bằng measure) nên không thừa/thiếu giấy.
+// rong-trai : BỀ RỘNG CỘT TRÁI — mặc định 70% khổ chữ; nhận cả tỉ lệ
+// (60%) lẫn độ dài tuyệt đối (11cm).
+// khoang : khe giữa hai cột
+// cao-dong : khoảng cách giữa hai hàng kẻ (mặc định 9mm)
+// mau, day : màu và độ dày hàng kẻ
+// ke : false = cột phải để trắng trơn (không kẻ dòng)
+// vach-ngan : true (mặc định) = vạch dọc mờ ngăn hai cột
+// tieu-de-phai: vd [Bài làm] — in nhạt ở đầu cột phải; none = không in
+#let chia-2-cot-lech(
+ rong-trai: 70%,
+ khoang: 10pt,
+ cao-dong: 9mm,
+ mau: luma(65%),
+ day: 0.4pt,
+ ke: true,
+ vach-ngan: true,
+ tieu-de-phai: none,
+ body,
+) = context {
+ if not _la-sach(_ho-so.get()) { body } else {
+ let (nd0, sau) = _tach-thoi-cot(body)
+ let nd = _cot-than(nd0)
+ if nd != none {
+ layout(kich => {
+ let wc = kich.width
+ let w0 = if type(rong-trai) == ratio { wc * rong-trai
+ } else if type(rong-trai) == relative { wc * rong-trai.ratio + rong-trai.length
+ } else { rong-trai }
+ // chốt chặn: cột trái không hẹp quá 2cm, không nuốt hết cột phải
+ let wt = calc.max(2cm, calc.min(w0, wc - khoang - 1.5cm))
+ let wp = wc - wt - khoang
+ if wp <= 0pt {
+ // không đủ chỗ cho cột phải ⇒ giữ nguyên lối một cột (an toàn)
+ nd
+ } else {
+ let cao = measure(block(width: wt, nd)).height
+ let so-dong = calc.min(4000, calc.max(1, int(calc.floor(cao / cao-dong))))
+ let dong-ke = block(
+ width: 100%, height: cao-dong, above: 0pt, below: 0pt, breakable: false,
+ place(bottom + left, line(length: 100%, stroke: day + mau)),
+ )
+ grid(
+ columns: (wt, wp), column-gutter: khoang,
+ block(width: 100%, nd),
+ block(
+ width: 100%,
+ stroke: if vach-ngan { (left: day + mau) } else { none },
+ inset: (left: if vach-ngan { 7pt } else { 0pt }),
+ above: 0pt, below: 0pt,
+ {
+ if tieu-de-phai != none {
+ block(above: 0pt, below: 3pt,
+ text(size: 0.85em, style: "italic", fill: mau.darken(25%), tieu-de-phai))
+ }
+ if ke { for _ in range(so-dong) { dong-ke } }
+ },
+ ),
+ )
+ }
+ })
+ }
+ if sau != none { sau }
+ }
+}
+
+// =====================================================================
+// #ke-het-trang — KẺ DÒNG LẤP ĐẦY CHỖ TRỐNG CÒN LẠI CỦA TRANG (08/2026)
+// ---------------------------------------------------------------------
+// Đặt ở đâu thì kẻ dòng từ chỗ đó xuống HẾT TRANG ĐÓ — số dòng tự tính, không
+// phải đếm tay:
+// #ke-het-trang() // nét chấm, cách 9mm
+// #ke-het-trang(cao-dong: 7mm, kieu: none) // nét liền, dày dòng hơn
+// #ke-het-trang(chua: 2cm) // chừa 2cm cuối trang
+// #ke-het-trang(them-trang: 1) // kẻ thêm TRỌN 1 trang nữa
+//
+// Cách làm: `here().position().y` cho biết đang ở đâu trên trang, trừ khỏi
+// `page.height` và lề dưới là ra chỗ còn lại, chia cho `cao-dong` ra số dòng.
+// Vị trí của chính lệnh này KHÔNG bị đẩy đi bởi các dòng kẻ nó sinh ra (chúng
+// nằm SAU nó) nên phép đo ổn định, không lặp vô hạn.
+//
+// Chỉ dựng ở bản in A4 (dethi/loigiai); beamer bỏ qua như #het/#bang-dap-an.
+// =====================================================================
+#let ke-het-trang(
+ cao-dong: 9mm, // khoảng cách giữa hai dòng kẻ
+ mau: luma(65%), // màu nét
+ day: 0.4pt, // độ dày nét
+ kieu: "dotted", // "dotted" | "dashed" | "dash-dotted" | none (nét liền)
+ dai: 100%, // bề dài dòng kẻ (50% = nửa bề ngang)
+ chua: 0pt, // chừa thêm chỗ trống ở đáy trang
+ le-tren: auto, // ghi đè lề trang nếu lề khai kiểu lạ
+ le-duoi: auto,
+ them-trang: 0, // kẻ thêm bao nhiêu TRANG ĐẦY nữa sau trang hiện tại
+) = context {
+ if not _la-sach(_ho-so.get()) { } else if type(page.height) != length { } else {
+ // ----- lấy lề trên / lề dưới từ thiết lập trang -----
+ let m = page.margin
+ let _le(khoa, mac-dinh) = {
+ if type(m) == dictionary { m.at(khoa, default: m.at("y", default: mac-dinh))
+ } else if m == auto { 2.5cm } else { m }
+ }
+ let lt = if le-tren != auto { le-tren } else { _le("top", 1.6cm) }
+ let ld = if le-duoi != auto { le-duoi } else { _le("bottom", 2.2cm) }
+
+ // MỘT dòng kẻ = MỘT khối cao ĐÚNG `cao-dong`, nét vẽ sát đáy khối.
+ // ⚠️ ĐỪNG viết `v(cao-dong); line(...)`: `line` tự tạo một ĐOẠN VĂN nên
+ // Typst cộng thêm khoảng cách đoạn trên/dưới ⇒ mỗi dòng chiếm gần GẤP ĐÔI
+ // `cao-dong`, số dòng tính ra thừa và tràn sang trang sau.
+ let net = (paint: mau, thickness: day, dash: kieu)
+ let mot-dong = block(
+ width: 100%, height: cao-dong, above: 0pt, below: 0pt, breakable: false,
+ place(bottom + left, line(length: dai, stroke: net)),
+ )
+
+ // ----- phần còn lại của TRANG HIỆN TẠI -----
+ // trừ thêm 1mm chống tràn (khoảng cách đoạn của khối đứng ngay trên lệnh
+ // có thể đẩy vị trí xuống một chút so với phép đo)
+ let con = page.height - here().position().y - ld - chua - 1mm
+ for _ in range(calc.max(0, int(calc.floor(con / cao-dong)))) { mot-dong }
+
+ // ----- các trang ĐẦY tiếp theo (nếu có) -----
+ if them-trang > 0 {
+ let cao-trang = page.height - lt - ld - chua
+ let n-day = calc.max(0, int(calc.floor(cao-trang / cao-dong)))
+ for _ in range(them-trang) {
+ pagebreak()
+ for _ in range(n-day) { mot-dong }
+ }
+ }
+ }
+}
+
+// =====================================================================
+// KẾT THÚC ĐỀ + BẢNG ĐÁP ÁN (chỉ dựng ở bản in A4 dethi/loigiai)
+// =====================================================================
+
+// ---------- #het — dòng kết thúc đề ----------
+// Dựng khối "––––– HẾT –––––" + ghi chú (căn giữa), chỉ hiện ở bản in A4;
+// ở beamer bỏ qua (không sinh slide thừa).
+// #het() -> mặc định
+// #het(ghi-chu: [Không được dùng tài liệu.])
+// #het(chu: [THE END], ghi-chu: none)
+#let het(
+ chu: [HẾT],
+ ghi-chu: [Thí sinh không được sử dụng tài liệu.\ Giám thị coi thi không giải thích gì thêm.],
+) = context {
+ if _la-sach(_ho-so.get()) {
+ v(14pt)
+ align(center, {
+ text(weight: "bold", size: 1.05em)[#("–" * 5) #h(4pt) #chu #h(4pt) #("–" * 5)]
+ if ghi-chu != none {
+ linebreak()
+ v(3pt)
+ text(style: "italic", size: 0.95em, ghi-chu)
+ }
+ })
+ }
+}
+
+// ---------- Cố gắng lấy CHUỖI từ đáp án (cho ô điền tln) ----------
+// str/số -> chuỗi; content -> đọc .text/.children/.body đệ quy; không tách
+// được -> none (khi đó bảng đáp án in nguyên nội dung vào một ô).
+#let _da-chuoi(c) = {
+ if c == none { return "" }
+ if type(c) == str { return c }
+ if type(c) == int or type(c) == float { return str(c) }
+ if type(c) != content { return none }
+ if c.has("text") { return c.text }
+ if c.has("children") {
+ let cac = c.children.map(_da-chuoi)
+ if cac.any(p => p == none) { return none }
+ return cac.join("")
+ }
+ if c.has("body") { return _da-chuoi(c.body) }
+ none
+}
+
+// ---------- #bang-dap-an — 3 bảng đáp án tn / ds / tln ----------
+// Tự thu thập đáp án MỌI câu tn/ds/tln trong tài liệu (qua metadata )
+// rồi dựng bảng theo mẫu đề 2025. Mỗi loại đánh số 1..n độc lập.
+// #bang-dap-an(ma-de: "0101")
+// Tham số:
+// ma-de : auto = LẤY TỰ ĐỘNG từ mã đề khai ở de-toan (đồng bộ, khỏi nhập
+// tay); none = không in mã đề; hoặc giá trị cụ thể để ghi đè.
+// tieu-de : auto = tự dựng theo ma-de; hoặc nội dung tuỳ ý; none = ẩn.
+// so-o-tln : số ô mỗi đáp án trả lời ngắn (mặc định 4; tự nới nếu dài hơn).
+// ngat-trang: true = sang trang mới trước bảng.
+#let bang-dap-an(
+ ma-de: auto,
+ tieu-de: auto,
+ so-o-tln: 4,
+ ngat-trang: true,
+) = context {
+ if not _la-sach(_ho-so.get()) { } else {
+ let ma-de = if ma-de == auto { _ma-de.get() } else { ma-de }
+ let items = query().map(m => m.value)
+ let ds-tn = items.filter(x => x.loai == "tn")
+ let ds-ds = items.filter(x => x.loai == "ds")
+ let ds-tln = items.filter(x => x.loai == "tln")
+ if ds-tn.len() + ds-ds.len() + ds-tln.len() > 0 {
+ if ngat-trang { pagebreak(weak: true) }
+
+ // ----- Tiêu đề -----
+ let td = if tieu-de == auto {
+ if ma-de != none [BẢNG ĐÁP ÁN MÃ ĐỀ #ma-de] else [BẢNG ĐÁP ÁN]
+ } else { tieu-de }
+ if td != none {
+ align(center, text(weight: "bold", size: 13pt, fill: _xanh, upper(td)))
+ v(8pt)
+ }
+
+ let tieu-muc(nd) = block(above: 12pt, below: 7pt,
+ text(weight: "bold", size: 1.05em, nd))
+
+ // ===== 1) Trắc nghiệm nhiều lựa chọn =====
+ if ds-tn.len() > 0 {
+ tieu-muc[Bảng đáp án các câu trắc nghiệm nhiều lựa chọn]
+ let o-tn(i, x) = box(stroke: 0.7pt + _xanh, radius: 2.5pt,
+ inset: (x: 6pt, y: 3.5pt), baseline: 30%)[
+ #text(fill: _xanh, weight: "bold")[#(i + 1).]~#text(weight: "bold")[#x.da]
+ ]
+ let cot = calc.min(ds-tn.len(), 12)
+ grid(columns: (auto,) * cot, column-gutter: 6pt, row-gutter: 7pt,
+ align: center + horizon,
+ ..ds-tn.enumerate().map(p => o-tn(p.at(0), p.at(1))))
+ }
+
+ // ===== 2) Đúng — Sai =====
+ if ds-ds.len() > 0 {
+ tieu-muc[Bảng đáp án các câu trắc nghiệm đúng sai]
+ let vong(dung) = if dung {
+ circle(radius: 7pt, fill: _xanh, stroke: none,
+ align(center + horizon, text(fill: white, weight: "bold", size: 8pt)[Đ]))
+ } else {
+ circle(radius: 7pt, fill: white, stroke: 0.9pt + luma(45%),
+ align(center + horizon, text(weight: "bold", size: 8pt)[S]))
+ }
+ let o-ds(i, x) = {
+ let bs = if type(x.da) == array { x.da } else { (false, false, false, false) }
+ box(stroke: 0.7pt + luma(45%), radius: 2.5pt, inset: (x: 8pt, y: 6pt),
+ baseline: 30%)[
+ #text(fill: _do, weight: "bold")[Câu #(i + 1).]~~#h(2pt)#box(baseline: 35%,
+ stack(dir: ltr, spacing: 3pt, ..bs.map(vong)))
+ ]
+ }
+ grid(columns: (auto,) * calc.min(ds-ds.len(), 4),
+ column-gutter: 8pt, row-gutter: 8pt, align: left + horizon,
+ ..ds-ds.enumerate().map(p => o-ds(p.at(0), p.at(1))))
+ }
+
+ // ===== 3) Trả lời ngắn =====
+ if ds-tln.len() > 0 {
+ tieu-muc[Bảng đáp án các câu trắc nghiệm trả lời ngắn]
+ let o-ky-tu(ky) = box(width: 17pt, height: 19pt, stroke: 0.7pt + luma(40%),
+ radius: 1.5pt, inset: 0pt, align(center + horizon,
+ if ky != none { text(size: 0.9em, ky) }))
+ let o-tln(i, x) = {
+ let s = _da-chuoi(x.da)
+ let noi = if s != none {
+ let kts = s.clusters()
+ let n = calc.max(so-o-tln, kts.len())
+ box(baseline: 35%, stack(dir: ltr, spacing: 3pt,
+ ..range(n).map(k => o-ky-tu(if k < kts.len() { kts.at(k) } else { none }))))
+ } else {
+ // Không tách được thành ký tự -> in nguyên nội dung vào một ô rộng.
+ box(stroke: 0.7pt + luma(40%), radius: 1.5pt, inset: (x: 5pt, y: 3pt),
+ baseline: 30%, x.da)
+ }
+ box(stroke: 0.7pt + luma(45%), radius: 2.5pt, inset: (x: 8pt, y: 6pt),
+ baseline: 30%)[
+ #text(fill: _do, weight: "bold")[Câu #(i + 1).]~~#h(2pt)#noi
+ ]
+ }
+ grid(columns: (auto,) * calc.min(ds-tln.len(), 3),
+ column-gutter: 8pt, row-gutter: 8pt, align: left + horizon,
+ ..ds-tln.enumerate().map(p => o-tln(p.at(0), p.at(1))))
+ }
+ }
+ }
+}
+
+// Bí danh tên cũ (tương thích ngược): mc = tn, tf = ds, sa = tln.
+#let mc = tn
+#let tf = ds
+#let sa = tln
+
+// TƯƠNG THÍCH NGƯỢC: các file cũ vẫn gọi được
+// #let (vd, tn, ds, tln, tl, hd, lt, vdtt, phan) = tao-cau-hoi(ho-so)
+// (tham số ho-so nay không còn cần thiết — hồ sơ đọc từ trạng thái).
+#let tao-cau-hoi(..thua) = (
+ vd: vd, tn: tn, ds: ds, tln: tln, tl: tl, hd: hd, lt: lt, vdtt: vdtt,
+ phan: phan, mc: tn, tf: ds, sa: tln,
+)
diff --git a/packages/preview/conic-toan/0.3.4/lib/da-dien.typ b/packages/preview/conic-toan/0.3.4/lib/da-dien.typ
new file mode 100644
index 0000000000..bb815b53cd
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/da-dien.typ
@@ -0,0 +1,876 @@
+// =====================================================================
+// da-dien.typ — ENGINE ĐA DIỆN TỔNG QUÁT (07/2026)
+//
+// Nhập DANH SÁCH ĐỈNH 3D + DANH SÁCH MẶT (mảng chỉ số đỉnh) là vẽ được
+// BẤT KÌ khối đa diện LỒI: tự chiếu, tự xác định mặt trước/mặt sau
+// (back-face culling), tự vẽ nét liền / nét khuất, tự tô mặt theo độ sâu.
+//
+// #da-dien(..khoi-chop-deu(n: 4, R: 2, cao: 3.6))
+// #da-dien(dinh: ((0,0,0), (3,0,0), (0,3,0), (0,0,3)),
+// mat: ((0,1,2), (0,1,3), (0,2,3), (1,2,3)),
+// ten: ($O$, $A$, $B$, $C$))
+//
+// QUY TẮC nét khuất: cạnh LIỀN nếu thuộc ít nhất một mặt hướng về người
+// nhìn, ĐỨT nếu chỉ thuộc các mặt hướng ra sau. Quy tắc này CHÍNH XÁC với
+// khối LỒI (chóp, lăng trụ, hộp, chóp cụt, tứ diện, bát diện… — tức toàn
+// bộ hình học THPT). Khối KHÔNG lồi hoặc hai khối che nhau thì cần z-buffer,
+// KHÔNG dùng được engine này.
+//
+// Thứ tự đỉnh trong mỗi mặt KHÔNG cần theo chiều nào: pháp tuyến được lật
+// ra ngoài theo tâm khối.
+// =====================================================================
+#import "ve.typ": *
+
+// Bí danh nội bộ: `ve-da-dien` có THAM SỐ tên `diem`/`duong`, mà tham số CHE
+// mất hàm cùng tên của ve.typ. Giữ tham chiếu tới hàm vẽ ở đây để dùng bên
+// trong thân hàm. (Đừng bỏ hai dòng này.)
+#let _ve-diem = diem
+#let _ve-duong = duong
+
+// =====================================================================
+// VECTƠ 3D — mọi hàm ở khối này TRẢ VỀ GIÁ TRỊ (không kê vào ve-voi)
+// =====================================================================
+#let v3-cong(A, B) = (A.at(0) + B.at(0), A.at(1) + B.at(1), A.at(2) + B.at(2))
+#let v3-tru(A, B) = (A.at(0) - B.at(0), A.at(1) - B.at(1), A.at(2) - B.at(2))
+#let v3-nhan(k, A) = (k * A.at(0), k * A.at(1), k * A.at(2))
+// Tích vô hướng (dot) và tích có hướng (cross).
+#let v3-vo-huong(A, B) = A.at(0) * B.at(0) + A.at(1) * B.at(1) + A.at(2) * B.at(2)
+#let v3-co-huong(A, B) = (
+ A.at(1) * B.at(2) - A.at(2) * B.at(1),
+ A.at(2) * B.at(0) - A.at(0) * B.at(2),
+ A.at(0) * B.at(1) - A.at(1) * B.at(0),
+)
+#let v3-dai(A) = calc.sqrt(calc.max(0.0, v3-vo-huong(A, A)))
+#let v3-chuan(A) = {
+ let d = v3-dai(A)
+ if d < 1e-12 { A } else { v3-nhan(1 / d, A) }
+}
+// Trung điểm, điểm chia theo tỉ lệ t (0 = A, 1 = B), tâm (trung bình) của mảng.
+#let trung-diem-3d(A, B) = v3-nhan(0.5, v3-cong(A, B))
+#let chia-3d(A, B, t) = v3-cong(A, v3-nhan(t, v3-tru(B, A)))
+#let tam-3d(ds) = v3-nhan(1 / ds.len(), ds.fold((0.0, 0.0, 0.0), v3-cong))
+
+// Pháp tuyến của đa giác phẳng (mảng điểm 3D) — công thức Newell, bền với
+// đa giác gần suy biến và không cần biết chiều quay.
+#let phap-da-giac(ds) = {
+ let n = ds.len()
+ let s = (0.0, 0.0, 0.0)
+ for i in range(n) {
+ let A = ds.at(i)
+ let B = ds.at(calc.rem(i + 1, n))
+ s = v3-cong(s, (
+ (A.at(1) - B.at(1)) * (A.at(2) + B.at(2)),
+ (A.at(2) - B.at(2)) * (A.at(0) + B.at(0)),
+ (A.at(0) - B.at(0)) * (A.at(1) + B.at(1)),
+ ))
+ }
+ v3-chuan(s)
+}
+
+// =====================================================================
+// CAMERA — phép chiếu song song + HƯỚNG NHÌN tự suy ra
+// Phép chiếu p là hàm tuyến tính (x, y, z) -> (u, v) toạ độ toán của khung.
+// Hai hàng của ma trận chiếu là hai trục màn hình (phải, lên); tích có hướng
+// của chúng chính là hướng TỪ hình VỀ PHÍA người nhìn ⇒ suy được mặt trước.
+// =====================================================================
+#let _nhin(p) = {
+ let o = p((0, 0, 0))
+ let c1 = p((1, 0, 0))
+ let c2 = p((0, 1, 0))
+ let c3 = p((0, 0, 1))
+ let r1 = (c1.at(0) - o.at(0), c2.at(0) - o.at(0), c3.at(0) - o.at(0))
+ let r2 = (c1.at(1) - o.at(1), c2.at(1) - o.at(1), c3.at(1) - o.at(1))
+ v3-chuan(v3-co-huong(r1, r2))
+}
+
+// Bọc một phép chiếu bất kì thành camera: (p: hàm chiếu, nhin: hướng nhìn).
+#let chieu-3d(p) = (p: p, nhin: _nhin(p))
+
+// Chiếu XIÊN mặc định (y = chiều sâu ra sau, z lên trên, x sang phải) —
+// KHỚP với phép chiếu của các hình chóp/lăng trụ dựng sẵn trong lib.
+#let chieu-xien(goc: 40deg, k: 0.5) = chieu-3d(P => (
+ P.at(0) + k * P.at(1) * calc.cos(goc),
+ P.at(2) + k * P.at(1) * calc.sin(goc),
+))
+
+// Chiếu KHỚP hệ trục #oxyz (Oy sang phải, Oz lên, Ox chéo xuống-trái về
+// phía người nhìn). Dùng khi vẽ khối trong `oxyz(them: (ctx, t3) => ...)`
+// — nhớ để k/goc trùng với oxyz.
+#let chieu-oxyz(k: 0.55, goc: 35deg) = chieu-3d(P => (
+ P.at(1) - k * P.at(0) * calc.cos(goc),
+ P.at(2) - k * P.at(0) * calc.sin(goc),
+))
+
+// Camera TRỰC GIAO thật theo góc Euler: quay quanh Oz góc `ngang`, nâng
+// cao `cao`. Oz luôn dựng đứng trên trang. `cao` lớn = nhìn từ trên xuống.
+#let chieu-truc-giao(ngang: 65deg, cao: 22deg, ti-le: 1) = {
+ let e = (
+ calc.cos(cao) * calc.cos(ngang),
+ calc.cos(cao) * calc.sin(ngang),
+ calc.sin(cao),
+ )
+ let r = (-calc.sin(ngang), calc.cos(ngang), 0)
+ let u = v3-co-huong(e, r)
+ (
+ p: P => (ti-le * v3-vo-huong(P, r), ti-le * v3-vo-huong(P, u)),
+ nhin: e,
+ )
+}
+
+// auto -> chiếu xiên mặc định · hàm -> tự suy hướng nhìn · dict -> dùng luôn.
+#let _cam(c) = {
+ if c == auto { chieu-xien() }
+ else if type(c) == function { chieu-3d(c) }
+ else { c }
+}
+
+// =====================================================================
+// PHÂN TÍCH KHỐI — TRẢ GIÁ TRỊ (không kê vào ve-voi)
+// .cam camera đã chuẩn hoá
+// .d2 mảng đỉnh đã chiếu (toạ độ toán 2D)
+// .sau độ sâu từng đỉnh (LỚN hơn = gần người nhìn hơn)
+// .mat mảng thông tin mặt: (chi-so, phap (ra ngoài), tam, sau, truoc)
+// .canh mảng cạnh duy nhất: (i, j, hien) — hien = false thì vẽ ĐỨT
+// =====================================================================
+#let _canh-vong(m) = {
+ let n = m.len()
+ range(n).map(i => (m.at(i), m.at(calc.rem(i + 1, n))))
+}
+#let _khoa(i, j) = if i < j { str(i) + "-" + str(j) } else { str(j) + "-" + str(i) }
+
+#let phan-tich-khoi(dinh, mat, cam: auto) = {
+ let cm = _cam(cam)
+ let e = cm.nhin
+ let C = tam-3d(dinh)
+ let d2 = dinh.map(cm.p)
+ let ttm = ()
+ for m in mat {
+ let n = phap-da-giac(m.map(i => dinh.at(i)))
+ let T = tam-3d(m.map(i => dinh.at(i)))
+ // lật pháp tuyến RA NGOÀI khối (khối lồi: ra xa tâm khối)
+ if v3-vo-huong(n, v3-tru(T, C)) < 0 { n = v3-nhan(-1, n) }
+ ttm.push((
+ chi-so: m,
+ phap: n,
+ tam: T,
+ sau: v3-vo-huong(T, e),
+ truoc: v3-vo-huong(n, e) > 1e-9,
+ ))
+ }
+ // cạnh: hiện nếu thuộc ÍT NHẤT MỘT mặt trước; mặt hở (cạnh 1 mặt) luôn hiện
+ let bang = (:)
+ for f in ttm {
+ for (i, j) in _canh-vong(f.chi-so) {
+ let k = _khoa(i, j)
+ let cu = bang.at(k, default: (i: calc.min(i, j), j: calc.max(i, j), hien: false, so: 0))
+ bang.insert(k, (i: cu.i, j: cu.j, hien: cu.hien or f.truoc, so: cu.so + 1))
+ }
+ }
+ let canh = bang.values().map(c => (
+ i: c.i,
+ j: c.j,
+ hien: if c.so == 1 { true } else { c.hien },
+ ))
+ (cam: cm, d2: d2, sau: dinh.map(P => v3-vo-huong(P, e)), mat: ttm, canh: canh)
+}
+
+// Cửa sổ bao các điểm 2D + lề (nội bộ).
+#let _cs2(pts, mg: 0.7) = {
+ let xs = pts.map(p => p.at(0))
+ let ys = pts.map(p => p.at(1))
+ (
+ xmin: calc.min(..xs) - mg, xmax: calc.max(..xs) + mg,
+ ymin: calc.min(..ys) - mg, ymax: calc.max(..ys) + mg,
+ )
+}
+
+// Hướng đặt nhãn cho đỉnh Q: PHÂN GIÁC của KHE GÓC RỘNG NHẤT giữa các cạnh
+// đi ra từ Q (ds-ke = các đỉnh kề, toạ độ toán 2D) — nhãn không đè lên cạnh
+// nào. Tính trên TOẠ ĐỘ TRANG nên đúng cả khi hai trục khác tỉ lệ.
+// Trả vectơ (dx, dy) trên trang (y hướng XUỐNG) — đúng dạng `huong` của `nhan`.
+#let _huong-khe(ctx, Q, ds-ke) = {
+ let q = toa-pt(ctx, Q)
+ let gs = ()
+ for R in ds-ke {
+ let r = toa-pt(ctx, R)
+ let dx = r.at(0) - q.at(0)
+ let dy = r.at(1) - q.at(1)
+ if calc.sqrt(dx * dx + dy * dy) > 1e-6 { gs.push(calc.atan2(dx, dy).deg()) }
+ }
+ if gs.len() == 0 { return (0, -1) }
+ let gs = gs.sorted()
+ let n = gs.len()
+ let khe = -1.0
+ let giua = 0.0
+ for i in range(n) {
+ let a = gs.at(i)
+ let b = if i + 1 < n { gs.at(i + 1) } else { gs.at(0) + 360 }
+ if b - a > khe {
+ khe = b - a
+ giua = (a + b) / 2
+ }
+ }
+ let g = giua * 1deg
+ (calc.cos(g), calc.sin(g))
+}
+
+// Hướng nhãn TOẢ RA XA tâm hình chiếu (cho điểm KHÔNG nằm trên cạnh nào).
+#let _huong-xa(Q, C2) = {
+ let dx = Q.at(0) - C2.at(0)
+ let dy = Q.at(1) - C2.at(1)
+ let l = calc.sqrt(dx * dx + dy * dy)
+ if l < 1e-9 { (0, -1) } else { (dx / l, -dy / l) }
+}
+
+// ---------------------------------------------------------------------
+// CHE BỞI CHÍNH KHỐI — kiểm CHÍNH XÁC (không quét mẫu, không cần z-buffer):
+// tia đi từ P về phía người nhìn có cắt khối LỒI hay không. Giao đoạn tham
+// số t của tia với mọi nửa không gian của khối rồi xem còn t > 0 nào không.
+// Nhất quán với quy tắc nét khuất của cạnh: điểm trên một mặt TRƯỚC thì
+// KHÔNG bị che; điểm trong khối hoặc trên mặt SAU thì bị che.
+// ---------------------------------------------------------------------
+#let _bi-khoi-che(P, e, mat-tt) = {
+ let lo = -1e9
+ let hi = 1e9
+ for f in mat-tt {
+ let df = v3-vo-huong(f.phap, e)
+ let f0 = v3-vo-huong(f.phap, P) - v3-vo-huong(f.phap, f.tam)
+ if calc.abs(df) < 1e-12 {
+ if f0 > 1e-9 { return false } // ở ngoài một mặt song song tia nhìn
+ } else if df > 0 {
+ hi = calc.min(hi, -f0 / df)
+ } else {
+ lo = calc.max(lo, -f0 / df)
+ }
+ }
+ calc.max(lo, 1e-6) <= hi - 1e-9
+}
+
+// Vẽ ĐOẠN PHỤ trong/quanh khối, TỰ phân loại nét: phần bị khối che vẽ ĐỨT,
+// phần thấy vẽ LIỀN (một đoạn có thể vừa liền vừa đứt — vd đường cao SO
+// xuất phát từ đỉnh S thấy được rồi chui vào trong khối).
+#let _doan-tu-dong(
+ ctx, p, e, mat-tt, A, B,
+ n: 160, mau: black, day: 1pt,
+ hien-khuat: true, mau-khuat: auto, day-khuat: auto, dut: auto,
+) = {
+ if dut != auto {
+ doan(ctx, p(A), p(B), mau: mau, day: day, dut: dut)
+ return
+ }
+ let moc = ()
+ let tt = none
+ let dau = 0.0
+ for st in range(n) {
+ let che = _bi-khoi-che(chia-3d(A, B, (st + 0.5) / n), e, mat-tt)
+ if tt == none {
+ tt = che
+ } else if che != tt {
+ moc.push((dau, st / n, tt))
+ tt = che
+ dau = st / n
+ }
+ }
+ moc.push((dau, 1.0, tt))
+ for (u, v, che) in moc {
+ if che and not hien-khuat { continue }
+ doan(
+ ctx, p(chia-3d(A, B, u)), p(chia-3d(A, B, v)),
+ mau: if not che or mau-khuat == auto { mau } else { mau-khuat },
+ day: if not che or day-khuat == auto { day } else { day-khuat },
+ dut: che,
+ )
+ }
+}
+
+// Cạnh của khối CHỨA điểm P (nếu có) — trả phần tử của `.canh`, hoặc none.
+#let _canh-chua(dinh, canh, P, eps: 1e-6) = {
+ for c in canh {
+ let A = dinh.at(c.i)
+ let B = dinh.at(c.j)
+ let AB = v3-tru(B, A)
+ let L2 = v3-vo-huong(AB, AB)
+ if L2 < 1e-12 { continue }
+ let t = v3-vo-huong(v3-tru(P, A), AB) / L2
+ if t < -eps or t > 1 + eps { continue }
+ let H = chia-3d(A, B, calc.max(0.0, calc.min(1.0, t)))
+ if v3-dai(v3-tru(P, H)) < eps { return c }
+ }
+ none
+}
+
+// Nhận dạng một điểm 3D trần (mảng 3 số) — phân biệt với mục (P, ten, ...).
+#let _la-diem3(x) = (
+ type(x) == array and x.len() == 3 and type(x.at(0)) != array
+)
+
+// ---------- Tiện ích dựng điểm (TRẢ GIÁ TRỊ) ----------
+// Điểm chia cạnh nối đỉnh thứ i và thứ j của khối (t = 0 tại i, 1 tại j).
+#let diem-canh(dinh, i, j, t: 0.5) = chia-3d(dinh.at(i), dinh.at(j), t)
+// Hình chiếu vuông góc của P lên ĐƯỜNG THẲNG AB.
+#let hinh-chieu-3d(P, A, B) = {
+ let AB = v3-tru(B, A)
+ let L2 = v3-vo-huong(AB, AB)
+ if L2 < 1e-12 { A } else {
+ v3-cong(A, v3-nhan(v3-vo-huong(v3-tru(P, A), AB) / L2, AB))
+ }
+}
+// Hình chiếu vuông góc của P lên MẶT PHẲNG mp = (n: pháp đơn vị, d:).
+#let hinh-chieu-mp(P, mp) = v3-tru(P, v3-nhan(v3-vo-huong(mp.n, P) - mp.d, mp.n))
+
+// =====================================================================
+// VẼ KHỐI ĐA DIỆN trong khung có sẵn (nhận ctx ⇒ KÊ vào ve-voi)
+// dinh mảng điểm 3D · mat mảng mặt (mảng chỉ số đỉnh)
+// ten mảng nhãn theo thứ tự đỉnh (none = không ghi)
+// huong auto = toả ra từ tâm hình chiếu · hoặc mảng hướng từng đỉnh
+// to màu tô CÁC MẶT TRƯỚC (vẽ xa -> gần) · to-mat mảng màu riêng
+// diem ĐIỂM PHỤ (trung điểm cạnh, chân đường cao…): mỗi mục là
+// P · (P, ten) · (P, ten, huong) · (P, ten, huong, mau).
+// Điểm nằm TRÊN CẠNH: nhãn tự đặt VUÔNG GÓC với cạnh đó; điểm bị
+// khối che thì `hien-khuat: false` sẽ ẩn luôn.
+// duong ĐOẠN PHỤ (đường cao, hình chiếu…): mỗi mục là (A, B) hoặc
+// (A, B, tuỳ-chọn) với tuỳ-chọn là dict nhận mau/day/ten/tai/huong/
+// cach/dut/hien-khuat/mau-khuat/day-khuat/vuong/r. Phần đoạn bị khối
+// che TỰ vẽ đứt. `vuong: C` = ký hiệu góc vuông tại B, giữa BA và BC.
+// them (ctx, p) => ... với p là hàm chiếu (x, y, z) -> điểm 2D
+// =====================================================================
+#let ve-da-dien(
+ ctx,
+ dinh: (), mat: (),
+ cam: auto,
+ ten: none, huong: auto, cach: 6pt, mau-ten: black,
+ hien-dinh: true, bk: 1.6pt,
+ mau: black, day: 1.1pt,
+ mau-khuat: auto, day-khuat: auto, hien-khuat: true,
+ to: none, to-mat: none,
+ diem: (), duong: (), mau-diem: auto, bk-diem: 1.8pt,
+ them: none,
+) = {
+ let pt = phan-tich-khoi(dinh, mat, cam: cam)
+ let d2 = pt.d2
+ // ---- tô mặt trước, xa vẽ trước (painter's algorithm)
+ if to != none or to-mat != none {
+ let thu-tu = range(pt.mat.len()).sorted(key: i => pt.mat.at(i).sau)
+ for i in thu-tu {
+ let f = pt.mat.at(i)
+ if not f.truoc { continue }
+ let c = if to-mat == none { to } else {
+ let v = to-mat.at(i, default: auto)
+ if v == auto { to } else { v }
+ }
+ if c != none {
+ da-giac-pt(f.chi-so.map(k => toa-pt(ctx, d2.at(k))), to: c)
+ }
+ }
+ }
+ // ---- cạnh khuất (vẽ trước) rồi cạnh liền
+ for lan in (false, true) {
+ for c in pt.canh {
+ if c.hien != lan { continue }
+ if not c.hien and not hien-khuat { continue }
+ doan(
+ ctx, d2.at(c.i), d2.at(c.j),
+ mau: if c.hien or mau-khuat == auto { mau } else { mau-khuat },
+ day: if c.hien or day-khuat == auto { day } else { day-khuat },
+ dut: not c.hien,
+ )
+ }
+ }
+ // ---- đoạn phụ (đường cao, hình chiếu…): tự liền/đứt theo khối
+ let p = pt.cam.p
+ let e = pt.cam.nhin
+ for m in duong {
+ let A = m.at(0)
+ let B = m.at(1)
+ let tc = if m.len() > 2 { m.at(2) } else { (:) }
+ let mc = tc.at("mau", default: mau)
+ let hk = tc.at("hien-khuat", default: hien-khuat)
+ _doan-tu-dong(
+ ctx, p, e, pt.mat, A, B,
+ mau: mc, day: tc.at("day", default: day),
+ hien-khuat: hk,
+ mau-khuat: tc.at("mau-khuat", default: mau-khuat),
+ day-khuat: tc.at("day-khuat", default: day-khuat),
+ dut: tc.at("dut", default: auto),
+ )
+ // ẩn nét khuất thì bỏ luôn ký hiệu góc vuông / nhãn ở chỗ bị khối che
+ if "vuong" in tc and (hk or not _bi-khoi-che(B, e, pt.mat)) {
+ goc-vuong(
+ ctx, p(B), p(A), p(tc.at("vuong")),
+ r: tc.at("r", default: 0.3), mau: mc, day: 0.8pt,
+ )
+ }
+ if tc.at("ten", default: none) != none {
+ let M = chia-3d(A, B, tc.at("tai", default: 0.5))
+ let hg = tc.at("huong", default: auto)
+ if hk or not _bi-khoi-che(M, e, pt.mat) {
+ nhan(
+ ctx, p(M), tc.at("ten"),
+ huong: if hg == auto { _phap-tuyen(ctx, p(A), p(B)) } else { hg },
+ cach: tc.at("cach", default: cach), mau: mc,
+ )
+ }
+ }
+ }
+ // ---- đỉnh + nhãn
+ // đỉnh kề (chỉ tính theo các cạnh ĐƯỢC VẼ) để đặt nhãn vào khe trống
+ let ke = (:)
+ for c in pt.canh {
+ if not c.hien and not hien-khuat { continue }
+ ke.insert(str(c.i), ke.at(str(c.i), default: ()) + (c.j,))
+ ke.insert(str(c.j), ke.at(str(c.j), default: ()) + (c.i,))
+ }
+ for i in range(dinh.len()) {
+ // ẩn nét khuất thì ẩn luôn đỉnh chỉ thuộc các mặt sau
+ if not hien-khuat and not pt.mat.any(f => f.truoc and i in f.chi-so) { continue }
+ let t = if ten == none { none } else { ten.at(i, default: none) }
+ let hg = if huong == auto {
+ _huong-khe(ctx, d2.at(i), ke.at(str(i), default: ()).map(j => d2.at(j)))
+ } else { huong.at(i, default: "above") }
+ if hien-dinh {
+ _ve-diem(ctx, d2.at(i), ten: t, huong: hg, bk: bk, cach: cach, mau: mau, mau-ten: mau-ten)
+ } else if t != none {
+ nhan(ctx, d2.at(i), t, huong: hg, cach: cach, mau: mau-ten)
+ }
+ }
+ // ---- điểm phụ: nhãn tự đặt vuông góc với cạnh chứa nó
+ let C2 = (
+ d2.map(q => q.at(0)).sum() / d2.len(),
+ d2.map(q => q.at(1)).sum() / d2.len(),
+ )
+ for m in diem {
+ let (P, t, hg, mc) = if _la-diem3(m) { (m, none, auto, auto) } else {
+ (m.at(0), m.at(1, default: none), m.at(2, default: auto), m.at(3, default: auto))
+ }
+ if not hien-khuat and _bi-khoi-che(P, e, pt.mat) { continue }
+ let c = _canh-chua(dinh, pt.canh, P)
+ let h = if hg != auto { hg } else if c != none {
+ _huong-khe(ctx, p(P), (d2.at(c.i), d2.at(c.j)))
+ } else { _huong-xa(p(P), C2) }
+ let m2 = if mc != auto { mc } else if mau-diem == auto { mau } else { mau-diem }
+ _ve-diem(ctx, p(P), ten: t, huong: h, bk: bk-diem, cach: cach, mau: m2, mau-ten: m2)
+ }
+ if them != none { them(ctx, p) }
+}
+
+// Các điểm 3D phụ (từ `diem`/`duong`) — để cửa sổ bao luôn phần chìa ra ngoài.
+#let _diem-phu(diem, duong) = {
+ let ds = ()
+ for m in diem { ds.push(if _la-diem3(m) { m } else { m.at(0) }) }
+ for m in duong {
+ ds.push(m.at(0))
+ ds.push(m.at(1))
+ if m.len() > 2 and "vuong" in m.at(2) { ds.push(m.at(2).at("vuong")) }
+ }
+ ds
+}
+
+// Bản TỰ TẠO KHUNG (không nhận ctx ⇒ KHÔNG kê vào ve-voi).
+#let da-dien(
+ dinh: (), mat: (),
+ cam: auto, w: 7cm, le: 0.75, co-chu: 10pt,
+ diem: (), duong: (),
+ ..dt,
+) = {
+ let cm = _cam(cam)
+ let cs = _cs2((dinh + _diem-phu(diem, duong)).map(cm.p), mg: le)
+ hinh(
+ w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ co-chu: co-chu,
+ ctx => ve-da-dien(
+ ctx, dinh: dinh, mat: mat, cam: cm, diem: diem, duong: duong, ..dt,
+ ),
+ )
+}
+
+// =====================================================================
+// KHỐI DỰNG SẴN — TRẢ (dinh:, mat:, ten:), dùng với toán tử `..`
+// #da-dien(..khoi-lang-tru-deu(n: 6, R: 1.8, cao: 3.4), to: blue.lighten(88%))
+// =====================================================================
+#let _chu = ("A", "B", "C", "D", "E", "F", "G", "H", "I", "K", "L", "M")
+#let _ten-day(n, phay: false) = range(n).map(i => {
+ let t = _chu.at(calc.rem(i, _chu.len()))
+ eval(if phay { t + "'" } else { t }, mode: "math")
+})
+// Pha mặc định của đa giác đều: đỉnh A rơi ra phía trước-trái.
+#let _pha(n) = if n == 4 { 225deg } else { 210deg }
+// Mảng đỉnh đa giác đều n cạnh, tâm trên trục Oz, bán kính R, cao z.
+#let day-deu(n, R, z: 0, pha: auto) = {
+ let ph = if pha == auto { _pha(n) } else { pha }
+ range(n).map(i => {
+ let a = ph + i * 360deg / n
+ (R * calc.cos(a), R * calc.sin(a), z)
+ })
+}
+
+// Hình chóp: đáy là mảng điểm 3D bất kì (đa giác lồi), đỉnh S.
+#let khoi-chop(day, S, ten: auto) = {
+ let n = day.len()
+ (
+ dinh: day + (S,),
+ mat: (range(n),) + range(n).map(i => (i, calc.rem(i + 1, n), n)),
+ ten: if ten == auto { _ten-day(n) + ($S$,) } else { ten },
+ )
+}
+// Lăng trụ: đáy `day` tịnh tiến theo vectơ v (đáy trên ghi dấu phẩy).
+#let khoi-lang-tru(day, v, ten: auto) = {
+ let n = day.len()
+ let tren = day.map(P => v3-cong(P, v))
+ (
+ dinh: day + tren,
+ mat: (range(n), range(n).map(i => i + n))
+ + range(n).map(i => {
+ let j = calc.rem(i + 1, n)
+ (i, j, j + n, i + n)
+ }),
+ ten: if ten == auto { _ten-day(n) + _ten-day(n, phay: true) } else { ten },
+ )
+}
+// Chóp cụt: hai đáy CÙNG số đỉnh, tương ứng theo thứ tự.
+#let khoi-chop-cut(day, day-tren, ten: auto) = {
+ let n = day.len()
+ (
+ dinh: day + day-tren,
+ mat: (range(n), range(n).map(i => i + n))
+ + range(n).map(i => {
+ let j = calc.rem(i + 1, n)
+ (i, j, j + n, i + n)
+ }),
+ ten: if ten == auto { _ten-day(n) + _ten-day(n, phay: true) } else { ten },
+ )
+}
+// Hình hộp tổng quát: đỉnh A + ba vectơ cạnh u, v, w.
+#let khoi-hop(A, u, v, w, ten: auto) = khoi-lang-tru(
+ (A, v3-cong(A, u), v3-cong(v3-cong(A, u), v), v3-cong(A, v)),
+ w,
+ ten: ten,
+)
+
+#let khoi-chop-deu(n: 4, R: 2, cao: 3.6, pha: auto, ten: auto) = khoi-chop(
+ day-deu(n, R, pha: pha), (0, 0, cao), ten: ten,
+)
+#let khoi-lang-tru-deu(n: 6, R: 1.8, cao: 3.4, pha: auto, ten: auto) = khoi-lang-tru(
+ day-deu(n, R, pha: pha), (0, 0, cao), ten: ten,
+)
+#let khoi-chop-cut-deu(n: 4, R: 2, r: 1.1, cao: 3, pha: auto, ten: auto) = khoi-chop-cut(
+ day-deu(n, R, pha: pha), day-deu(n, r, z: cao, pha: pha), ten: ten,
+)
+#let khoi-hop-chu-nhat(dai: 4, rong: 2.6, cao: 3, ten: auto) = khoi-hop(
+ (0, 0, 0), (dai, 0, 0), (0, rong, 0), (0, 0, cao), ten: ten,
+)
+#let khoi-lap-phuong(a: 3, ten: auto) = khoi-hop-chu-nhat(
+ dai: a, rong: a, cao: a, ten: ten,
+)
+// Tứ diện đều cạnh a: đáy đều bán kính a/√3, chiều cao a·√(2/3).
+#let khoi-tu-dien-deu(a: 3, ten: auto) = khoi-chop(
+ day-deu(3, a / calc.sqrt(3)),
+ (0, 0, a * calc.sqrt(2 / 3)),
+ ten: if ten == auto { ($A$, $B$, $C$, $D$) } else { ten },
+)
+// Bát diện đều cạnh a: 6 đỉnh (±s, 0, 0), (0, ±s, 0), (0, 0, ±s), s = a/√2.
+#let khoi-bat-dien-deu(a: 3, ten: auto) = {
+ let s = a / calc.sqrt(2)
+ (
+ dinh: ((s, 0, 0), (0, s, 0), (-s, 0, 0), (0, -s, 0), (0, 0, s), (0, 0, -s)),
+ mat: (
+ (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4),
+ (0, 1, 5), (1, 2, 5), (2, 3, 5), (3, 0, 5),
+ ),
+ ten: if ten == auto { ($A$, $B$, $C$, $D$, $S$, $S'$) } else { ten },
+ )
+}
+
+// =====================================================================
+// MẶT PHẲNG
+// mp = (n: pháp tuyến ĐƠN VỊ, d: hằng số) ⇔ n · P = d
+// =====================================================================
+#let mp-qua-phap(P, n) = {
+ let u = v3-chuan(n)
+ (n: u, d: v3-vo-huong(u, P))
+}
+#let mp-qua-3-diem(A, B, C) = mp-qua-phap(A, v3-co-huong(v3-tru(B, A), v3-tru(C, A)))
+// Mặt phẳng cắt ba trục tại (a,0,0), (0,b,0), (0,0,c): x/a + y/b + z/c = 1.
+#let mp-cat-truc(a, b, c) = mp-qua-3-diem((a, 0, 0), (0, b, 0), (0, 0, c))
+// Mặt phẳng song song với mp, đi qua P.
+#let mp-song-song(mp, P) = (n: mp.n, d: v3-vo-huong(mp.n, P))
+
+// Điểm 2D có nằm trong đa giác LỒI (mảng điểm 2D) hay không.
+#let _trong-dg2(q, dg) = {
+ let duong = false
+ let am = false
+ let n = dg.len()
+ for i in range(n) {
+ let A = dg.at(i)
+ let B = dg.at(calc.rem(i + 1, n))
+ let c = (
+ (B.at(0) - A.at(0)) * (q.at(1) - A.at(1))
+ - (B.at(1) - A.at(1)) * (q.at(0) - A.at(0))
+ )
+ if c > 1e-9 { duong = true }
+ if c < -1e-9 { am = true }
+ }
+ not (duong and am)
+}
+
+// Vẽ ĐỨT phần đoạn AB (3D) bị đa giác `dg3` che: nằm SAU mặt phẳng chứa
+// đa giác VÀ hình chiếu rơi vào trong đa giác. Quét mẫu nên đúng với mọi
+// cấu hình (đoạn cắt ngang mặt phẳng, ló ra hai đầu…).
+#let _dut-bi-che(ctx, p, nhin, dg3, A, B, n: 240, mau: black, day: 0.9pt) = {
+ let dg2 = dg3.map(p)
+ let nm = phap-da-giac(dg3)
+ let d0 = v3-vo-huong(nm, dg3.at(0))
+ // người nhìn ở phía f > 0 nếu nm cùng hướng nhìn; điểm SAU mặt phẳng: f·phia < 0
+ let phia = if v3-vo-huong(nm, nhin) >= 0 { 1 } else { -1 }
+ let dai = ()
+ let dau = none
+ for s in range(n + 1) {
+ let t = s / n
+ let P = chia-3d(A, B, t)
+ let f = (v3-vo-huong(nm, P) - d0) * phia
+ let che = f < -1e-9 and _trong-dg2(p(P), dg2)
+ if che and dau == none { dau = t }
+ if not che and dau != none {
+ dai.push((dau, t))
+ dau = none
+ }
+ }
+ if dau != none { dai.push((dau, 1)) }
+ for (u, v) in dai {
+ doan(ctx, p(chia-3d(A, B, u)), p(chia-3d(A, B, v)), mau: mau, day: day, dut: true)
+ }
+}
+
+// ---------------------------------------------------------------------
+// Vẽ MỘT MẶT PHẲNG (đa giác phẳng 3D) — lõi chung, nhận ctx.
+// p hàm chiếu (x, y, z) -> 2D (vd t3 của #oxyz)
+// dinh3 mảng đỉnh 3D của đa giác biểu diễn mặt phẳng
+// che mảng đoạn 3D ((A, B), ...) sẽ được vẽ ĐỨT ở phần bị mặt phẳng che
+// ---------------------------------------------------------------------
+#let mat-phang(
+ ctx, p, dinh3,
+ to: auto, mau: blue.darken(15%), day: 1pt, dut: false,
+ ten: none, ten-tai: 0, huong: "above-left", cach: 5pt,
+ che: (), mau-che: black, day-che: 0.9pt,
+ nhin: auto,
+ them: none,
+) = {
+ let e = if nhin == auto { _nhin(p) } else { nhin }
+ let d2 = dinh3.map(p)
+ let mau-to = if to == auto { rgb(80, 140, 220, 46) } else { to }
+ if mau-to != none { da-giac-pt(d2.map(q => toa-pt(ctx, q)), to: mau-to) }
+ let n = d2.len()
+ for i in range(n) {
+ doan(ctx, d2.at(i), d2.at(calc.rem(i + 1, n)), mau: mau, day: day, dut: dut)
+ }
+ for (A, B) in che {
+ _dut-bi-che(ctx, p, e, dinh3, A, B, mau: mau-che, day: day-che)
+ }
+ if ten != none {
+ nhan(ctx, d2.at(calc.rem(ten-tai, n)), ten, huong: huong, cach: cach, mau: mau)
+ }
+ if them != none { them(ctx, p) }
+}
+
+// ---------------------------------------------------------------------
+// MẶT PHẲNG CẮT BA TRỤC trong hệ #oxyz — tam giác A(a,0,0) B(0,b,0) C(0,0,c),
+// tự vẽ ĐỨT phần ba trục toạ độ nằm SAU mặt phẳng (bị mặt phẳng che).
+// #oxyz(x: 5, y: 6, z: 5, them: (ctx, t3) => {
+// mat-phang-oxyz(ctx, t3, 4, 5, 3, ten-dinh: true, ten: $(P)$)
+// })
+// `truc: auto` tự lấy phạm vi (0 -> mốc cắt trục); truyền tuple 3 khoảng để
+// đổi, vd truc: ((-1, 5), (-1, 6), (-1, 5)); truc: none = không xử lí nét đứt.
+// ---------------------------------------------------------------------
+#let mat-phang-oxyz(
+ ctx, t3, a, b, c,
+ to: auto, mau: blue.darken(15%), day: 1pt, dut: false,
+ ten: none, ten-tai: 0, huong: "above-left", cach: 5pt,
+ ten-dinh: false, ten-abc: ($A$, $B$, $C$), hien-dinh: true, bk: 1.8pt,
+ truc: auto, mau-che: black, day-che: 0.9pt,
+ them: none,
+) = {
+ let A = (a, 0, 0)
+ let B = (0, b, 0)
+ let C = (0, 0, c)
+ let ds-truc = if truc == none { () } else if truc == auto {
+ (((0, 0, 0), A), ((0, 0, 0), B), ((0, 0, 0), C))
+ } else {
+ (
+ ((truc.at(0).at(0), 0, 0), (truc.at(0).at(1), 0, 0)),
+ ((0, truc.at(1).at(0), 0), (0, truc.at(1).at(1), 0)),
+ ((0, 0, truc.at(2).at(0)), (0, 0, truc.at(2).at(1))),
+ )
+ }
+ mat-phang(
+ ctx, t3, (A, B, C),
+ to: to, mau: mau, day: day, dut: dut,
+ ten: ten, ten-tai: ten-tai, huong: huong, cach: cach,
+ che: ds-truc, mau-che: mau-che, day-che: day-che,
+ )
+ if ten-dinh or hien-dinh {
+ let bo = ((A, ten-abc.at(0), "below-left"), (B, ten-abc.at(1), "below-right"), (C, ten-abc.at(2), "above-right"))
+ for (P, t, h) in bo {
+ diem(
+ ctx, t3(P),
+ ten: if ten-dinh { t } else { none },
+ huong: h, bk: if hien-dinh { bk } else { 0pt }, cach: 5pt, mau: mau,
+ )
+ }
+ }
+ if them != none { them(ctx, t3) }
+}
+
+// ---------------------------------------------------------------------
+// MẶT PHẲNG "LƠ LỬNG" dạng hình bình hành: tâm `tam`, hai nửa-vectơ u, v
+// (4 đỉnh = tam ± u ± v). Ba trục toạ độ đi sau nó cũng tự vẽ đứt.
+// mat-phang-bh(ctx, t3, (2, 2, 2), (2.4, 0, 0), (0, 2.4, 0), ten: $(alpha)$)
+// ---------------------------------------------------------------------
+#let mat-phang-bh(
+ ctx, t3, tam, u, v,
+ to: auto, mau: blue.darken(15%), day: 1pt, dut: false,
+ ten: none, ten-tai: 1, huong: "above-right", cach: 5pt,
+ truc: none, mau-che: black, day-che: 0.9pt,
+ them: none,
+) = {
+ let dinh3 = (
+ v3-tru(v3-tru(tam, u), v),
+ v3-tru(v3-cong(tam, u), v),
+ v3-cong(v3-cong(tam, u), v),
+ v3-cong(v3-tru(tam, u), v),
+ )
+ let ds-truc = if truc == none or truc == auto { () } else {
+ (
+ ((truc.at(0).at(0), 0, 0), (truc.at(0).at(1), 0, 0)),
+ ((0, truc.at(1).at(0), 0), (0, truc.at(1).at(1), 0)),
+ ((0, 0, truc.at(2).at(0)), (0, 0, truc.at(2).at(1))),
+ )
+ }
+ mat-phang(
+ ctx, t3, dinh3,
+ to: to, mau: mau, day: day, dut: dut,
+ ten: ten, ten-tai: ten-tai, huong: huong, cach: cach,
+ che: ds-truc, mau-che: mau-che, day-che: day-che,
+ them: them,
+ )
+}
+
+// =====================================================================
+// THIẾT DIỆN — cắt khối LỒI bởi một mặt phẳng
+// thiet-dien TRẢ VỀ mảng điểm 3D đã SẮP THEO VÒNG (rỗng nếu không cắt).
+// Cách làm: lấy mọi giao điểm của mặt phẳng với các CẠNH của khối (kể cả
+// đỉnh nằm đúng trên mặt phẳng), bỏ điểm trùng, rồi sắp theo GÓC quanh tâm
+// trong hệ hai vectơ của mặt phẳng — thiết diện của khối lồi là đa giác lồi
+// nên phép sắp theo góc cho đúng thứ tự vòng.
+// =====================================================================
+#let thiet-dien(dinh, mat, mp, eps: 1e-7) = {
+ let f = P => v3-vo-huong(mp.n, P) - mp.d
+ let bang = (:)
+ for m in mat {
+ for (i, j) in _canh-vong(m) {
+ bang.insert(_khoa(i, j), (calc.min(i, j), calc.max(i, j)))
+ }
+ }
+ let ds = ()
+ for (i, j) in bang.values() {
+ let A = dinh.at(i)
+ let B = dinh.at(j)
+ let fa = f(A)
+ let fb = f(B)
+ if calc.abs(fa) < eps { ds.push(A) }
+ if calc.abs(fb) < eps { ds.push(B) }
+ if calc.abs(fa) >= eps and calc.abs(fb) >= eps and fa * fb < 0 {
+ ds.push(chia-3d(A, B, fa / (fa - fb)))
+ }
+ }
+ let kq = ()
+ for P in ds {
+ if not kq.any(Q => v3-dai(v3-tru(P, Q)) < 1e-6) { kq.push(P) }
+ }
+ if kq.len() < 3 { return () }
+ let T = tam-3d(kq)
+ let u = v3-chuan(v3-tru(kq.at(0), T))
+ let v = v3-co-huong(mp.n, u)
+ kq.sorted(key: P => {
+ let w = v3-tru(P, T)
+ calc.atan2(v3-vo-huong(w, u), v3-vo-huong(w, v)).deg()
+ })
+}
+
+// Vẽ thiết diện TRÊN một khối: tô miền + viền LIỀN ở phần nằm trên mặt
+// THẤY, ĐỨT ở phần nằm trên mặt KHUẤT (nhận ctx ⇒ kê vào ve-voi).
+// ve-thiet-dien(ctx, dinh: d, mat: m, mp: mp-qua-3-diem(P, Q, R))
+#let ve-thiet-dien(
+ ctx,
+ dinh: (), mat: (), mp: none,
+ cam: auto,
+ to: auto, mau: red.darken(10%), day: 1.1pt,
+ hien-khuat: true, mau-khuat: auto, day-khuat: auto,
+ ten: none, huong: auto, cach: 6pt, hien-dinh: true, bk: 1.8pt, mau-ten: auto,
+ them: none,
+) = {
+ let pt = phan-tich-khoi(dinh, mat, cam: cam)
+ let p = pt.cam.p
+ let dd = thiet-dien(dinh, mat, mp)
+ if dd.len() < 3 { return }
+ let d2 = dd.map(p)
+ let mau-to = if to == auto { rgb(220, 70, 70, 40) } else { to }
+ if mau-to != none { da-giac-pt(d2.map(q => toa-pt(ctx, q)), to: mau-to) }
+ let n = dd.len()
+ for i in range(n) {
+ let j = calc.rem(i + 1, n)
+ let M = trung-diem-3d(dd.at(i), dd.at(j))
+ // cạnh thiết diện nằm trên mặt nào? thấy nếu có một mặt CHỨA nó là mặt trước
+ let hien = pt.mat.any(f => (
+ calc.abs(v3-vo-huong(f.phap, M) - v3-vo-huong(f.phap, f.tam)) < 1e-5 and f.truoc
+ ))
+ if not hien and not hien-khuat { continue }
+ doan(
+ ctx, d2.at(i), d2.at(j),
+ mau: if hien or mau-khuat == auto { mau } else { mau-khuat },
+ day: if hien or day-khuat == auto { day } else { day-khuat },
+ dut: not hien,
+ )
+ }
+ for i in range(n) {
+ let t = if ten == none { none } else { ten.at(i, default: none) }
+ let hg = if huong == auto {
+ _huong-khe(ctx, d2.at(i), (d2.at(calc.rem(i + n - 1, n)), d2.at(calc.rem(i + 1, n))))
+ } else { huong.at(i, default: "above") }
+ if hien-dinh {
+ diem(ctx, d2.at(i), ten: t, huong: hg, bk: bk, cach: cach, mau: mau, mau-ten: if mau-ten == auto { mau } else { mau-ten })
+ } else if t != none {
+ nhan(ctx, d2.at(i), t, huong: hg, cach: cach, mau: if mau-ten == auto { mau } else { mau-ten })
+ }
+ }
+ if them != none { them(ctx, p) }
+}
+
+// Khối + thiết diện trong MỘT lệnh, tự tạo khung (không nhận ctx).
+#let da-dien-thiet-dien(
+ dinh: (), mat: (), mp: none,
+ cam: auto, w: 7cm, le: 0.75, co-chu: 10pt,
+ ten: none, huong: auto,
+ mau: black, day: 1.1pt, to: none, to-mat: none,
+ hien-khuat: true, hien-dinh: true, mau-ten: black,
+ diem: (), duong: (), mau-diem: auto, bk-diem: 1.8pt,
+ td: (:), // tuỳ chọn riêng cho thiết diện (mau, to, ten, …)
+ them: none,
+) = {
+ let cm = _cam(cam)
+ let cs = _cs2((dinh + _diem-phu(diem, duong)).map(cm.p), mg: le)
+ hinh(
+ w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ co-chu: co-chu,
+ ctx => {
+ ve-da-dien(
+ ctx, dinh: dinh, mat: mat, cam: cm,
+ ten: ten, huong: huong, mau: mau, day: day, to: to, to-mat: to-mat,
+ hien-khuat: hien-khuat, hien-dinh: hien-dinh, mau-ten: mau-ten,
+ diem: diem, duong: duong, mau-diem: mau-diem, bk-diem: bk-diem,
+ )
+ if mp != none {
+ ve-thiet-dien(ctx, dinh: dinh, mat: mat, mp: mp, cam: cm, ..td)
+ }
+ if them != none { them(ctx, cm.p) }
+ },
+ )
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/do-thi.typ b/packages/preview/conic-toan/0.3.4/lib/do-thi.typ
new file mode 100644
index 0000000000..9171225f75
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/do-thi.typ
@@ -0,0 +1,1632 @@
+// =====================================================================
+// do-thi.typ — ĐỒ THỊ HÀM SỐ THPT
+// Hệ trục, lưới, vạch chia, đường gióng + các đồ thị dựng sẵn:
+// bậc nhất, bậc hai, bậc ba, trùng phương, phân thức b1/b1, hữu tỉ b2/b1,
+// mũ, lôgarit, sin/cos/tan, căn thức.
+//
+// CỬA SỔ THÔNG MINH: khung nhìn tự tính từ các điểm then chốt
+// (cực trị, điểm uốn, tâm đối xứng, tiệm cận, giao trục) và luôn chứa gốc O.
+//
+// THAM SỐ NHÃN CHUNG cho các đồ thị dựng sẵn:
+// cuc-tri : auto = gióng cực trị vào 2 trục + nhãn tự động
+// none = không gióng
+// (x: "below", y: "left") = đổi hướng nhãn (mọi điểm)
+// ((x:.., y:..), (x:.., y:..), ...) = hướng riêng từng điểm
+// (thứ tự x tăng dần)
+// giao-ox : none | auto | "below" | ("below", "above", ...) — nhãn giao Ox
+// giao-oy : none | auto | "left" — nhãn giao Oy
+// Hướng nhãn (tiếng Anh): above, below, left, right,
+// above-left, above-right, below-left, below-right.
+// Tiệm cận (nếu có) LUÔN được vẽ kèm nhãn trên trục.
+// goc-ten : vị trí nhãn tên hàm — "above-right" (mặc định) | "above-left"
+// | "below-left" | "below-right" | (x, y) toạ độ tuỳ ý
+// dan-x, dan-y : co/giãn cửa sổ quanh tâm theo từng trục —
+// > 1 phóng to (đơn vị lớn hơn), < 1 thu nhỏ (thấy vùng rộng hơn)
+// Mỗi đồ thị dựng sẵn có `them: ctx => ...` để vẽ chồng nội dung tuỳ ý.
+// =====================================================================
+#import "ve.typ": *
+
+// Định dạng số gọn: 2 -> "2", 2.50 -> "2.5"
+#let so-dep(v) = {
+ let r = calc.round(v, digits: 2)
+ if calc.fract(r) == 0 { str(int(r)) } else { str(r) }
+}
+
+// Nhãn toán "đẹp": nhận dạng số nguyên, phân số p/q (q ≤ 20),
+// căn thức k√n (n ≤ 50), dạng a ± k√n (ví dụ 1 + √2).
+// Không khớp thì trả về số thập phân.
+#let so-toan(v) = {
+ let eps = 0.0001
+ if calc.abs(v - calc.round(v)) < eps { return $#int(calc.round(v))$ }
+ for q in range(2, 21) {
+ let p = calc.round(v * q)
+ if calc.abs(v - p / q) < eps and calc.gcd(int(calc.abs(p)), q) == 1 {
+ let p = int(p)
+ return if p < 0 { $- #(-p) / #q$ } else { $#p / #q$ }
+ }
+ }
+ // phân số mẫu LỚN (vd 283/27 — giá trị cực trị bậc ba, mẫu 27a²):
+ // khớp CHẶT (1e-9) nên không "nhận nhầm" số vô tỉ thành phân số.
+ for q in range(21, 401) {
+ let p = calc.round(v * q)
+ if calc.abs(v - p / q) < 1e-9 and calc.gcd(int(calc.abs(p)), q) == 1 {
+ let p = int(p)
+ return if p < 0 { $- #(-p) / #q$ } else { $#p / #q$ }
+ }
+ }
+ for n in range(2, 51) {
+ let r = calc.sqrt(n)
+ for k in range(1, 6) {
+ if calc.abs(calc.abs(v) - k * r) < eps {
+ let ct = if k == 1 { $sqrt(#[#n])$ } else { $#k sqrt(#[#n])$ }
+ return if v < 0 { $-#ct$ } else { ct }
+ }
+ }
+ }
+ for n in range(2, 31) {
+ let cn = calc.sqrt(n)
+ if calc.abs(cn - calc.round(cn)) < eps { continue }
+ for k in range(1, 4) {
+ let ct = if k == 1 { $sqrt(#[#n])$ } else { $#k sqrt(#[#n])$ }
+ let du = v - k * cn // v = a + k√n ?
+ if calc.abs(du - calc.round(du)) < eps and calc.abs(du) > eps and calc.abs(calc.round(du)) <= 12 {
+ return $#int(calc.round(du)) + #ct$
+ }
+ let du = v + k * cn // v = a - k√n ?
+ if calc.abs(du - calc.round(du)) < eps and calc.abs(du) > eps and calc.abs(calc.round(du)) <= 12 {
+ return $#int(calc.round(du)) - #ct$
+ }
+ }
+ }
+ // dạng (m ± √n)/q — nghiệm phương trình bậc hai (vd (1+√7)/3, √3/2)
+ for q in range(2, 13) {
+ let t = v * q
+ let m0 = int(calc.round(t))
+ for dm in range(-16, 17) {
+ let m = m0 + dm
+ let s = t - m // s = ±√n
+ let n = calc.round(s * s)
+ if n >= 2 and n <= 400 and calc.abs(s * s - n) < 0.00001 {
+ let cn = calc.sqrt(n)
+ if calc.abs(cn - calc.round(cn)) < eps { continue } // n chính phương -> hữu tỉ, đã xét
+ // rút thừa số chính phương: √96 -> 4√6
+ let n = int(n)
+ let k = 1
+ let j = 2
+ while j * j <= n {
+ if calc.rem(n, j * j) == 0 { k = j }
+ j = j + 1
+ }
+ let n = int(n / (k * k))
+ let ct = if k == 1 { $sqrt(#[#n])$ } else { $#k sqrt(#[#n])$ }
+ return if m == 0 {
+ if s > 0 { $#ct / #q$ } else { $- #ct / #q$ }
+ } else if s > 0 { $(#m + #ct) / #q$ } else { $(#m - #ct) / #q$ }
+ }
+ }
+ }
+ $#so-dep(v)$
+}
+
+// Căn thức CHÍNH XÁC: hiển thị (P + K·√n)/Q từ các thành phần đã biết
+// (P, K, Q gần-nguyên; n hữu tỉ ≥ 0). Tự chuẩn hoá n hữu tỉ (√(u/den) =
+// √(u·den)/den), rút thừa số chính phương, rút gọn gcd(P, K, Q).
+// Không "đẹp" được thì rơi về so-toan(giá trị số) — không tệ hơn trước.
+#let so-can-thuc(P, K, n, Q) = {
+ let eps = 0.000001
+ let gan-int(x) = calc.abs(x - calc.round(x)) < eps * calc.max(1, calc.abs(x))
+ if Q == 0 or n < -eps { return $#so-dep(0)$ } // phòng hờ (không xảy ra)
+ let gia = (P + K * calc.sqrt(calc.max(n, 0))) / Q
+ // chuẩn hoá n về nguyên: (P + K√(u/den))/Q = (P·den + K√(u·den))/(Q·den)
+ let cap = if gan-int(n) { (calc.round(n), P, Q) } else {
+ let ket = none
+ for den in range(2, 21) {
+ if gan-int(n * den) {
+ ket = (calc.round(n * den) * den, P * den, Q * den)
+ break
+ }
+ }
+ ket
+ }
+ if cap == none { return so-toan(gia) }
+ let (n, P, Q) = cap
+ if not (gan-int(P) and gan-int(K) and gan-int(Q)) { return so-toan(gia) }
+ let P = int(calc.round(P))
+ let K = int(calc.round(K))
+ let Q = int(calc.round(Q))
+ let n = int(calc.round(n))
+ if Q < 0 { P = -P; K = -K; Q = -Q }
+ // rút thừa số chính phương: √96 -> 4√6
+ if K != 0 and n > 1 {
+ let j = 2
+ let h = 1
+ while j * j <= n {
+ if calc.rem(n, j * j) == 0 { h = j }
+ j = j + 1
+ }
+ K = K * h
+ n = int(n / (h * h))
+ }
+ if n == 1 { P = P + K; K = 0 }
+ if K == 0 or n == 0 {
+ // hữu tỉ P/Q chính xác
+ let g = calc.max(calc.gcd(calc.abs(P), Q), 1)
+ let (P, Q) = (int(P / g), int(Q / g))
+ return if Q == 1 { $#P$ } else if P < 0 { $- #(-P) / #Q$ } else { $#P / #Q$ }
+ }
+ let g = calc.max(calc.gcd(calc.gcd(calc.abs(P), calc.abs(K)), Q), 1)
+ let (P, K, Q) = (int(P / g), int(K / g), int(Q / g))
+ let ct = if calc.abs(K) == 1 { $sqrt(#[#n])$ } else { $#calc.abs(K) sqrt(#[#n])$ }
+ if Q == 1 {
+ if P == 0 { if K > 0 { ct } else { $-#ct$ } }
+ else if K > 0 { $#P + #ct$ } else { $#P - #ct$ }
+ } else {
+ if P == 0 { if K > 0 { $#ct / #Q$ } else { $- #ct / #Q$ } }
+ else if K > 0 { $(#P + #ct) / #Q$ } else { $(#P - #ct) / #Q$ }
+ }
+}
+
+// ---- Cực trị CHÍNH XÁC theo hệ số (dùng chung cho bang.typ + khao-sat.typ) ----
+// Bậc ba y = ax³+bx²+cx+d, Δ' = b²−3ac > 0: trả (x1:, y1:, x2:, y2:) với
+// x1 < x2. x = (−b ∓ σ√Δ')/(3a), σ = sign(a);
+// y = (P0 ± 2σΔ'√Δ')/(27a²), P0 = 2b³ − 9abc + 27a²d (chia đa thức f cho f').
+#let cuc-tri-bac-ba(a, b, c, d) = {
+ let delta = b * b - 3 * a * c
+ let sg = if a > 0 { 1 } else { -1 }
+ let P0 = 2 * b * b * b - 9 * a * b * c + 27 * a * a * d
+ (
+ x1: so-can-thuc(-b, -sg, delta, 3 * a),
+ y1: so-can-thuc(P0, 2 * sg * delta, delta, 27 * a * a),
+ x2: so-can-thuc(-b, sg, delta, 3 * a),
+ y2: so-can-thuc(P0, -2 * sg * delta, delta, 27 * a * a),
+ )
+}
+// Hữu tỉ y = (ax²+bx+c)/(dx+e) có 2 cực trị: E = ae² − bde + cd² (E/a > 0),
+// x = (−e ∓ σ√(E/a))/d, σ = sign(d); tại cực trị y = (2ax+b)/d
+// ⇒ y = (bd − 2ae ∓ 2aσ√(E/a))/d², x1 < x2.
+#let cuc-tri-huu-ti(a, b, c, d, e) = {
+ let E = a * e * e - b * d * e + c * d * d
+ let sg = if d > 0 { 1 } else { -1 }
+ (
+ x1: so-can-thuc(-e, -sg, E / a, d),
+ y1: so-can-thuc(b * d - 2 * a * e, -2 * a * sg, E / a, d * d),
+ x2: so-can-thuc(-e, sg, E / a, d),
+ y2: so-can-thuc(b * d - 2 * a * e, 2 * a * sg, E / a, d * d),
+ )
+}
+
+// Nội dung "y = px + q" rút gọn (nhãn tiệm cận xiên).
+#let _pt-bac-nhat(p, q) = {
+ let hs = if calc.abs(p - 1) < 0.001 { $x$ } else if calc.abs(p + 1) < 0.001 { $-x$ } else { $#so-toan(p) x$ }
+ if calc.abs(q) < 0.001 { $y = #hs$ } else if q > 0 { $y = #hs + #so-toan(q)$ } else { $y = #hs - #so-toan(-q)$ }
+}
+
+// ---------- Đạo hàm số & tiếp tuyến ----------
+// Đạo hàm của f tại x bằng sai phân trung tâm (đủ chính xác để vẽ hình).
+// let k = dao-ham(f, 1) // hệ số góc tiếp tuyến tại x = 1
+#let dao-ham(f, x, h: auto) = {
+ let b = if h == auto { 0.0001 * calc.max(1, calc.abs(x)) } else { h }
+ (f(x + b) - f(x - b)) / (2 * b)
+}
+
+// TIẾP TUYẾN của đồ thị y = f(x) tại điểm có hoành độ x0 (hoặc DÃY hoành độ).
+// Hệ số góc lấy bằng đạo hàm số nên dùng được cho MỌI hàm f viết bằng closure.
+// tiep-tuyen(f, 1) // tiếp tuyến tại x = 1
+// tiep-tuyen(f, (-1, 2), mau: green) // nhiều tiếp điểm
+// tiep-tuyen(f, 1, ten: auto) // tự ghi y = kx + m
+// tiep-tuyen(f, 1, ten: $Delta$, ten-diem: $M$) // đặt tên tuỳ ý
+// Tham số:
+// dai : auto = kéo hết bề ngang khung (tự cắt gọn theo cửa sổ);
+// số = nửa độ dài đoạn tiếp tuyến theo trục Ox quanh tiếp điểm
+// cham : chấm tiếp điểm · ten-diem/huong-diem: nhãn tiếp điểm
+// ten : nhãn đường tiếp tuyến (auto = phương trình y = kx + m rút gọn)
+// tai/huong-ten/cach/ten-quay: vị trí & kiểu nhãn (như `ten:` của `doan`)
+// giong : true = kẻ đường gióng đứt từ tiếp điểm về 2 trục
+#let tiep-tuyen(
+ ctx, f, x0,
+ mau: red, day: 1.2pt, dut: false, dai: auto,
+ cham: true, mau-cham: auto, ten-diem: none, huong-diem: "above-left",
+ ten: none, tai: 0.9, huong-ten: auto, cach: 6pt, ten-quay: false,
+ giong: false, h: auto,
+) = {
+ for x in (if type(x0) == array { x0 } else { (x0,) }) {
+ let y = f(x)
+ let k = dao-ham(f, x, h: h)
+ let tt = u => y + k * (u - x)
+ // hai đầu đoạn tiếp tuyến: cắt gọn trong cửa sổ hoặc theo `dai`
+ let (X1, X2) = if dai != auto { (x - dai, x + dai) } else if calc.abs(k) > 0.0001 {
+ let ca = ((ctx.ymin - y) / k + x, (ctx.ymax - y) / k + x).sorted()
+ (calc.max(ctx.xmin, ca.at(0)), calc.min(ctx.xmax, ca.at(1)))
+ } else { (ctx.xmin, ctx.xmax) }
+ if X2 - X1 > 0.0001 {
+ doan(
+ ctx, (X1, tt(X1)), (X2, tt(X2)),
+ mau: mau, day: day, dut: dut,
+ ten: if ten == auto { _pt-bac-nhat(k, y - k * x) } else { ten },
+ tai: tai, huong: huong-ten, cach: cach, ten-quay: ten-quay, mau-ten: mau,
+ )
+ }
+ if giong {
+ doan(ctx, (x, y), (x, 0), mau: gray.darken(20%), day: 0.8pt, dut: true)
+ doan(ctx, (x, y), (0, y), mau: gray.darken(20%), day: 0.8pt, dut: true)
+ }
+ if cham {
+ diem(ctx, (x, y), bk: 2.2pt, mau: if mau-cham == auto { mau } else { mau-cham },
+ ten: ten-diem, huong: huong-diem)
+ }
+ }
+}
+
+// ---------- Hệ trục Oxy ----------
+#let truc(ctx, ten-x: $x$, ten-y: $y$, ten-goc: $O$, mau: black, day: 0.9pt) = {
+ mui-ten(ctx, (ctx.xmin, 0), (ctx.xmax, 0), mau: mau, day: day)
+ mui-ten(ctx, (0, ctx.ymin), (0, ctx.ymax), mau: mau, day: day)
+ nhan(ctx, (ctx.xmax, 0), ten-x, huong: "below", cach: 5pt)
+ nhan(ctx, (0, ctx.ymax), ten-y, huong: "left", cach: 5pt)
+ if ten-goc != none { nhan(ctx, (0, 0), ten-goc, huong: "below-left", cach: 4pt) }
+}
+
+// Lưới ô vuông mờ.
+#let luoi(ctx, buoc: 1, mau: luma(87%), day: 0.5pt) = {
+ let x = calc.ceil(ctx.xmin / buoc) * buoc
+ while x <= ctx.xmax + 0.0001 {
+ doan(ctx, (x, ctx.ymin), (x, ctx.ymax), mau: mau, day: day)
+ x = x + buoc
+ }
+ let y = calc.ceil(ctx.ymin / buoc) * buoc
+ while y <= ctx.ymax + 0.0001 {
+ doan(ctx, (ctx.xmin, y), (ctx.xmax, y), mau: mau, day: day)
+ y = y + buoc
+ }
+}
+
+// Vạch chia + số trên hai trục (bỏ qua 0).
+#let vach-chia(ctx, buoc-x: 1, buoc-y: 1, so: true, mau: black) = {
+ let t = 3pt / ctx.sy // nửa chiều dài vạch, đổi sang đơn vị toạ độ
+ let x = calc.ceil((ctx.xmin + 0.3) / buoc-x) * buoc-x
+ while x <= ctx.xmax - 0.3 {
+ if calc.abs(x) > 0.0001 {
+ doan(ctx, (x, -t), (x, t), mau: mau, day: 0.8pt)
+ if so { nhan(ctx, (x, 0), $#so-dep(x)$, huong: "below", cach: 4pt) }
+ }
+ x = x + buoc-x
+ }
+ let s = 3pt / ctx.sx
+ let y = calc.ceil((ctx.ymin + 0.3) / buoc-y) * buoc-y
+ while y <= ctx.ymax - 0.3 {
+ if calc.abs(y) > 0.0001 {
+ doan(ctx, (-s, y), (s, y), mau: mau, day: 0.8pt)
+ if so { nhan(ctx, (0, y), $#so-dep(y)$, huong: "left", cach: 4pt) }
+ }
+ y = y + buoc-y
+ }
+}
+
+// Hệ trục TRỌN GÓI: lưới + trục + vạch chia + số — một lệnh thay ba.
+// #hinh(xmin: -4, xmax: 4, ymin: -3, ymax: 3, ctx => { he-truc(ctx) ... })
+// luoi-o: lưới ô vuông mờ · vach: vạch chia đơn vị · so: ghi số trên vạch
+#let he-truc(
+ ctx,
+ ten-x: $x$, ten-y: $y$, ten-goc: $O$,
+ luoi-o: true, buoc-luoi: auto, // auto = theo buoc-x
+ vach: true, so: true, buoc-x: 1, buoc-y: 1,
+ mau: black, day: 0.9pt, mau-luoi: luma(87%),
+) = {
+ if luoi-o {
+ luoi(ctx, buoc: if buoc-luoi == auto { buoc-x } else { buoc-luoi }, mau: mau-luoi)
+ }
+ truc(ctx, ten-x: ten-x, ten-y: ten-y, ten-goc: ten-goc, mau: mau, day: day)
+ if vach { vach-chia(ctx, buoc-x: buoc-x, buoc-y: buoc-y, so: so) }
+}
+
+// Đường gióng từ điểm P về 2 trục (nét đứt) + nhãn toạ độ trên trục.
+// ten-x / ten-y: auto = tự ghi số; none = không ghi; hoặc nội dung tuỳ ý.
+#let giong(
+ ctx, P,
+ ten-x: auto, ten-y: auto,
+ huong-x: "below", huong-y: "left",
+ mau: gray.darken(20%), mau-diem: red, diem-to: true,
+) = {
+ // điểm nằm ngay trên một trục thì bỏ đường gióng (tránh đè lên trục)
+ if calc.abs(P.at(0)) > 0.0001 and calc.abs(P.at(1)) > 0.0001 {
+ doan(ctx, P, (P.at(0), 0), mau: mau, day: 0.8pt, dut: true)
+ doan(ctx, P, (0, P.at(1)), mau: mau, day: 0.8pt, dut: true)
+ }
+ if diem-to { diem(ctx, P, bk: 1.9pt, mau: mau-diem) }
+ if ten-x != none and calc.abs(P.at(0)) > 0.0001 {
+ let tx = if ten-x == auto { so-toan(P.at(0)) } else { ten-x }
+ nhan(ctx, (P.at(0), 0), tx, huong: huong-x, cach: 4pt)
+ }
+ if ten-y != none and calc.abs(P.at(1)) > 0.0001 {
+ let ty = if ten-y == auto { so-toan(P.at(1)) } else { ten-y }
+ nhan(ctx, (0, P.at(1)), ty, huong: huong-y, cach: 4pt)
+ }
+}
+
+// ---------- Bộ trợ giúp cửa sổ & nhãn (dùng nội bộ) ----------
+// Cửa sổ thông minh: bao các "điểm chốt" + gốc O, thêm lề tương xứng,
+// bảo đảm kích thước tối thiểu.
+#let _cua-so(diem-chot, le: 0.16, rong-min: 4.2, cao-min: 3.4, goc: true) = {
+ let pts = diem-chot
+ if goc { pts = pts + ((0, 0),) }
+ let xs = pts.map(p => p.at(0))
+ let ys = pts.map(p => p.at(1))
+ let (x1, x2) = (calc.min(..xs), calc.max(..xs))
+ let (y1, y2) = (calc.min(..ys), calc.max(..ys))
+ let px = calc.max((x2 - x1) * le, 0.65)
+ let py = calc.max((y2 - y1) * le, 0.6)
+ let (xa, xb, ya, yb) = (x1 - px, x2 + px, y1 - py, y2 + py)
+ if xb - xa < rong-min {
+ let t = (rong-min - (xb - xa)) / 2
+ xa = xa - t
+ xb = xb + t
+ }
+ if yb - ya < cao-min {
+ let t = (cao-min - (yb - ya)) / 2
+ ya = ya - t
+ yb = yb + t
+ }
+ (xmin: xa, xmax: xb, ymin: ya, ymax: yb)
+}
+
+// Nghiệm của f trên [a, b] bằng chia đôi (chỉ bắt nghiệm đổi dấu).
+#let _nghiem(f, a, b, n: 160) = {
+ let kq = ()
+ let m = lay-mau(f, a, b, n: n)
+ for i in range(n) {
+ let (x1, y1) = m.at(i)
+ let (x2, y2) = m.at(i + 1)
+ if y1 * y2 <= 0 and (y1 != 0 or y2 != 0) {
+ let (lo, hi) = (x1, x2)
+ for _ in range(36) {
+ let g = (lo + hi) / 2
+ if f(lo) * f(g) <= 0 { hi = g } else { lo = g }
+ }
+ let x = (lo + hi) / 2
+ if kq.len() == 0 or x - kq.last() > (b - a) / n * 1.5 { kq.push(x) }
+ }
+ }
+ kq
+}
+
+// Lấy hướng thứ i từ tuỳ chọn none|auto|chuỗi|tuple chuỗi.
+#let _huong-i(tuy-chon, i, mac-dinh) = {
+ if type(tuy-chon) == str { tuy-chon } else if type(tuy-chon) == array and i < tuy-chon.len() { tuy-chon.at(i) } else { mac-dinh }
+}
+
+// Gióng điểm cực trị thứ i theo tuỳ chọn cuc-tri (xem đầu file).
+// ten-x/ten-y: nhãn CHÍNH XÁC (căn thức/phân số) tính sẵn từ hệ số — dùng khi
+// người dùng không tự đặt tên; auto = lấy số từ toạ độ (so-toan).
+#let _giong-ct(ctx, P, tuy-chon, i, ten-x: auto, ten-y: auto) = {
+ if tuy-chon == none or tuy-chon == false { return }
+ let o = if type(tuy-chon) == array {
+ if i < tuy-chon.len() { tuy-chon.at(i) } else { (:) }
+ } else if type(tuy-chon) == dictionary { tuy-chon } else { (:) }
+ giong(
+ ctx, P,
+ huong-x: o.at("x", default: "below"),
+ huong-y: o.at("y", default: "left"),
+ ten-x: o.at("ten-x", default: ten-x),
+ ten-y: o.at("ten-y", default: ten-y),
+ )
+}
+
+// Nhãn các giao điểm với Ox (cac-x: mảng hoành độ đã tính sẵn).
+// ten: mảng nhãn CHÍNH XÁC song song với cac-x (thiếu thì tự lấy so-toan).
+#let _nhan-giao-ox(ctx, cac-x, tuy-chon, ten: ()) = {
+ if tuy-chon == none { return }
+ let le = (ctx.xmax - ctx.xmin) * 0.02
+ let i = 0
+ let j = 0
+ for x in cac-x {
+ // giao điểm trùng gốc O thì bỏ qua hẳn
+ if x >= ctx.xmin + le and x <= ctx.xmax - le and calc.abs(x) > 0.001 {
+ diem(ctx, (x, 0), bk: 1.9pt, mau: red)
+ let nd = if j < ten.len() { ten.at(j) } else { so-toan(x) }
+ nhan(ctx, (x, 0), nd, huong: _huong-i(tuy-chon, i, "below"), cach: 4pt)
+ i = i + 1
+ }
+ j = j + 1
+ }
+}
+
+// Nhãn giao điểm với Oy (y = f(0)).
+#let _nhan-giao-oy(ctx, y, tuy-chon) = {
+ if tuy-chon == none or y == none { return }
+ // giao điểm trùng gốc O thì bỏ qua hẳn
+ if y < ctx.ymin or y > ctx.ymax or ctx.xmin > 0 or ctx.xmax < 0 or calc.abs(y) <= 0.001 { return }
+ diem(ctx, (0, y), bk: 1.9pt, mau: red)
+ nhan(ctx, (0, y), so-toan(y), huong: _huong-i(tuy-chon, 0, "left"), cach: 4pt)
+}
+
+// Chuẩn hoá true/false cũ về auto/none.
+#let _chuan-tc(t) = if t == true { auto } else if t == false { none } else { t }
+
+// Nhãn tên hàm đặt ở 1 trong 4 góc hoặc toạ độ (x, y) tuỳ ý.
+// goc-ten: "above-right" (mặc định) | "above-left" | "below-left"
+// | "below-right" | (x, y)
+#let _nhan-ten(ctx, ten, goc, mau) = {
+ if ten == none { return }
+ let dx = ctx.xmax - ctx.xmin
+ let dy = ctx.ymax - ctx.ymin
+ let P = if type(goc) == array { goc } else {
+ (
+ above-right: (ctx.xmax - dx * 0.14, ctx.ymax - dy * 0.09),
+ above-left: (ctx.xmin + dx * 0.14, ctx.ymax - dy * 0.09),
+ below-left: (ctx.xmin + dx * 0.14, ctx.ymin + dy * 0.09),
+ below-right: (ctx.xmax - dx * 0.14, ctx.ymin + dy * 0.09),
+ ).at(goc, default: (ctx.xmax - dx * 0.14, ctx.ymax - dy * 0.09))
+ }
+ // nền trắng để đường cong không xuyên qua nhãn
+ nhan(ctx, P, box(fill: white, inset: (x: 2.5pt, y: 1pt), radius: 1.5pt, ten), huong: "center", mau: mau)
+}
+
+// Co/giãn cửa sổ quanh tâm: dan > 1 phóng to (đơn vị trục lớn hơn),
+// dan < 1 thu nhỏ (nhìn được vùng rộng hơn). Dùng khi đơn vị quá lớn/bé.
+#let _dan(cs, dan-x, dan-y) = {
+ if dan-x == 1 and dan-y == 1 { return cs }
+ let cx = (cs.xmin + cs.xmax) / 2
+ let cy = (cs.ymin + cs.ymax) / 2
+ let hx = (cs.xmax - cs.xmin) / 2 / dan-x
+ let hy = (cs.ymax - cs.ymin) / 2 / dan-y
+ (xmin: cx - hx, xmax: cx + hx, ymin: cy - hy, ymax: cy + hy)
+}
+
+// ---------- Đồ thị tổng quát ----------
+// Vẽ hàm f bất kỳ trên hệ trục hoàn chỉnh, tự tạo khung.
+#let do-thi-ham(
+ f,
+ w: 8cm, h: auto,
+ xmin: -4, xmax: 4, ymin: -3, ymax: 3,
+ tu: auto, den: auto,
+ mau: blue, day: 1.4pt, n: 150,
+ ten: none, // nhãn hàm (vd $y = x^2$)
+ goc-ten: "above-right", // vị trí nhãn: 4 góc hoặc toạ độ (x, y)
+ luoi-o: false,
+ vach: false, // true => vạch chia bước 1
+ dan-x: 1, dan-y: 1, // co/giãn cửa sổ quanh tâm (xem đầu file)
+ them: none, // ctx => nội dung vẽ thêm
+) = {
+ let hh = if h == auto { w * 0.68 } else { h }
+ let cs = _dan((xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax), dan-x, dan-y)
+ hinh(w: w, h: hh, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax, ctx => {
+ if luoi-o { luoi(ctx) }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ ve-ham(ctx, f, tu: tu, den: den, n: n, mau: mau, day: day,)
+ _nhan-ten(ctx, ten, goc-ten, mau)
+ if them != none { them(ctx) }
+ })
+}
+
+// ---------- Bậc nhất: y = ax + b ----------
+#let do-thi-bac-nhat(
+ a, b,
+ w: 7cm, mau: blue, ten: auto, goc-ten: "above-right",
+ giao-ox: auto, giao-oy: auto, giao-truc: true,
+ luoi-o: false, vach: false,
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let f = x => a * x + b
+ let chot = ((0, b),)
+ if a != 0 { chot = chot + ((-b / a, 0),) }
+ let cs = _dan(_cua-so(chot, rong-min: 4.6), dan-x, dan-y)
+ do-thi-ham(
+ f,
+ w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ mau: mau, luoi-o: luoi-o, vach: vach,
+ ten: if ten == auto { none } else { ten },
+ goc-ten: goc-ten,
+ them: ctx => {
+ if giao-truc {
+ if a != 0 { _nhan-giao-ox(ctx, (-b / a,), giao-ox) }
+ _nhan-giao-oy(ctx, b, giao-oy)
+ }
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Bậc hai: y = ax² + bx + c ----------
+// Cửa sổ tự tính từ đỉnh + giao trục.
+#let do-thi-bac-hai(
+ a, b, c,
+ w: 7.6cm, mau: blue, ten: none, goc-ten: "above-right",
+ dinh: auto, // gióng đỉnh: auto | none | (x: "below", y: "left")
+ giao-ox: auto, giao-oy: auto,
+ truc-dx: false, // trục đối xứng
+ luoi-o: false, vach: false,
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let f = x => a * x * x + b * x + c
+ let xv = -b / (2 * a)
+ let yv = c - b * b / (4 * a)
+ let dinh = _chuan-tc(dinh)
+ // điểm chốt: đỉnh, giao Oy, giao Ox (nếu có)
+ let chot = ((xv, yv), (0, c))
+ let dlt = b * b - 4 * a * c
+ let ng = ()
+ if dlt > 0 {
+ let s = calc.sqrt(dlt) / (2 * calc.abs(a))
+ ng = (xv - s, xv + s)
+ chot = chot + ((xv - s, 0), (xv + s, 0))
+ }
+ // cánh parabol: mở cửa sổ theo bề ngang các điểm chốt
+ let xs = chot.map(p => p.at(0)) + (0,)
+ let mien = calc.max(calc.max(..xs) - calc.min(..xs), 3.0)
+ let (e1, e2) = (f(xv - 0.62 * mien), f(xv + 0.62 * mien))
+ let ye = if a > 0 { calc.min(e1, e2) } else { calc.max(e1, e2) }
+ let cs = _dan(_cua-so(chot + ((xv - 0.62 * mien, ye), (xv + 0.62 * mien, ye)), rong-min: 4.6), dan-x, dan-y)
+ do-thi-ham(
+ f,
+ w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ mau: mau, ten: ten, goc-ten: goc-ten, luoi-o: luoi-o, vach: vach,
+ them: ctx => {
+ if truc-dx and calc.abs(xv) > 0.001 {
+ doan(ctx, (xv, cs.ymin), (xv, cs.ymax), mau: red, day: 0.8pt, dut: true)
+ }
+ _giong-ct(
+ ctx, (xv, yv), dinh, 0,
+ ten-x: so-can-thuc(-b, 0, 0, 2 * a),
+ ten-y: so-can-thuc(4 * a * c - b * b, 0, 0, 4 * a),
+ )
+ _nhan-giao-ox(
+ ctx, ng, giao-ox,
+ ten: if dlt > 0 {
+ let sg = if a > 0 { 1 } else { -1 }
+ (so-can-thuc(-b, -sg, dlt, 2 * a), so-can-thuc(-b, sg, dlt, 2 * a))
+ } else { () },
+ )
+ _nhan-giao-oy(ctx, c, giao-oy)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Bậc ba: y = ax³ + bx² + cx + d ----------
+// Cửa sổ tự tính từ 2 cực trị (nếu có) hoặc điểm uốn (nếu không).
+#let do-thi-bac-ba(
+ a, b, c, d,
+ w: 7.6cm, mau: blue, ten: none, goc-ten: "above-right",
+ cuc-tri: auto, // gióng 2 điểm cực trị (nếu có)
+ diem-uon: none, // gióng điểm uốn: none | auto | (x:.., y:..)
+ giao-ox: none, giao-oy: none,
+ luoi-o: false, vach: false,
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let f = x => ((a * x + b) * x + c) * x + d
+ let delta = b * b - 3 * a * c
+ let tam = -b / (3 * a)
+ let cuc-tri = _chuan-tc(cuc-tri)
+ let (x1, x2) = (none, none)
+ let chot = ((tam, f(tam)),)
+ if delta > 0 {
+ let s = calc.sqrt(delta) / (3 * calc.abs(a))
+ x1 = tam - s
+ x2 = tam + s
+ chot = chot + (
+ (x1, f(x1)), (x2, f(x2)),
+ (tam - 2.1 * s, f(tam - 2.1 * s)), (tam + 2.1 * s, f(tam + 2.1 * s)),
+ )
+ } else {
+ // không cực trị: bán kính quanh điểm uốn sao cho biến thiên y vừa mắt
+ let m = c - b * b / (3 * a)
+ let bien = t => calc.abs(a * t * t * t + m * t)
+ let r = 1.0
+ while bien(r) < 4 and r < 6 { r = r * 1.25 }
+ while bien(r) > 7 and r > 0.9 { r = r / 1.15 }
+ chot = chot + ((tam - r, f(tam - r)), (tam + r, f(tam + r)))
+ }
+ // giao Ox (tìm số) chỉ khi cần nhãn
+ let ng = if giao-ox != none {
+ let bk = 1 + calc.max(calc.abs(b), calc.abs(c), calc.abs(d)) / calc.abs(a)
+ _nghiem(f, tam - calc.min(bk, 12), tam + calc.min(bk, 12))
+ } else { () }
+ let cs = _dan(_cua-so(chot + ng.map(x => (x, 0))), dan-x, dan-y)
+ do-thi-ham(
+ f,
+ w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 180, luoi-o: luoi-o, vach: vach,
+ them: ctx => {
+ if x1 != none {
+ // giá trị cực trị CHÍNH XÁC (phân số/căn thức) thay vì số thập phân
+ let ct = cuc-tri-bac-ba(a, b, c, d)
+ _giong-ct(ctx, (x1, f(x1)), cuc-tri, 0, ten-x: ct.x1, ten-y: ct.y1)
+ _giong-ct(ctx, (x2, f(x2)), cuc-tri, 1, ten-x: ct.x2, ten-y: ct.y2)
+ }
+ _giong-ct(
+ ctx, (tam, f(tam)), diem-uon, 0,
+ ten-x: so-can-thuc(-b, 0, 0, 3 * a),
+ ten-y: so-can-thuc(2 * b * b * b - 9 * a * b * c + 27 * a * a * d, 0, 0, 27 * a * a),
+ )
+ _nhan-giao-ox(ctx, ng, giao-ox)
+ _nhan-giao-oy(ctx, d, giao-oy)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Trùng phương: y = ax⁴ + bx² + c ----------
+// Cửa sổ tự tính từ 3 cực trị (a·b < 0) hoặc 1 cực trị (0, c).
+#let do-thi-trung-phuong(
+ a, b, c,
+ w: 7.6cm, mau: blue, ten: none, goc-ten: "above-right",
+ cuc-tri: auto,
+ giao-ox: none, giao-oy: none,
+ luoi-o: false, vach: false,
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let f = x => a * calc.pow(x, 4) + b * x * x + c
+ let co-3ct = a * b < 0
+ let cuc-tri = _chuan-tc(cuc-tri)
+ let xc = if co-3ct { calc.sqrt(-b / (2 * a)) } else { 0 }
+ let chot = ((0, c),)
+ if co-3ct {
+ chot = chot + (
+ (xc, f(xc)), (-xc, f(xc)),
+ (1.45 * xc, f(1.45 * xc)), (-1.45 * xc, f(1.45 * xc)),
+ )
+ } else {
+ let bien = t => calc.abs(f(t) - c)
+ let r = 1.0
+ while bien(r) < 4 and r < 5 { r = r * 1.25 }
+ while bien(r) > 7 and r > 0.8 { r = r / 1.15 }
+ chot = chot + ((r, f(r)), (-r, f(r)))
+ }
+ let ng = if giao-ox != none {
+ let xs = chot.map(p => p.at(0))
+ _nghiem(f, calc.min(..xs) - 0.5, calc.max(..xs) + 0.5)
+ } else { () }
+ let cs = _dan(_cua-so(chot + ng.map(x => (x, 0))), dan-x, dan-y)
+ do-thi-ham(
+ f,
+ w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 180, luoi-o: luoi-o, vach: vach,
+ them: ctx => {
+ if cuc-tri != none {
+ if co-3ct {
+ let Xc = so-can-thuc(0, 1, -b / (2 * a), 1)
+ let nXc = so-can-thuc(0, -1, -b / (2 * a), 1)
+ let Yc = so-can-thuc(4 * a * c - b * b, 0, 0, 4 * a)
+ _giong-ct(ctx, (-xc, f(xc)), cuc-tri, 0, ten-x: nXc, ten-y: Yc)
+ _giong-ct(ctx, (0, c), cuc-tri, 1)
+ _giong-ct(ctx, (xc, f(xc)), cuc-tri, 2, ten-x: Xc, ten-y: Yc)
+ } else {
+ _giong-ct(ctx, (0, c), cuc-tri, 0)
+ }
+ }
+ _nhan-giao-ox(ctx, ng, giao-ox)
+ _nhan-giao-oy(ctx, c, giao-oy)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Phân thức bậc nhất/bậc nhất: y = (ax + b)/(cx + d) ----------
+// Hai nhánh hypebol; tiệm cận LUÔN vẽ + nhãn trên trục.
+// Cửa sổ tự tính từ tâm đối xứng (x0, y0), giao trục và gốc O.
+#let do-thi-phan-thuc(
+ a, b, c, d,
+ w: 7.6cm, mau: blue, ten: none, goc-ten: "above-right",
+ giao-ox: none, giao-oy: none,
+ huong-x0: "below-left", huong-y0: "above-left", // hướng nhãn 2 tiệm cận
+ luoi-o: false, vach: false,
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let x0 = -d / c // tiệm cận đứng
+ let y0 = a / c // tiệm cận ngang
+ let f = x => (a * x + b) / (c * x + d)
+ let chot = ((x0, y0), (x0 - 1.7, y0 - 1.7), (x0 + 1.7, y0 + 1.7))
+ if a != 0 { chot = chot + ((-b / a, 0),) }
+ if d != 0 { chot = chot + ((0, b / d),) }
+ let cs = _dan(_cua-so(chot, le: 0.2), dan-x, dan-y)
+ hinh(
+ w: w, h: w * 0.74,
+ xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ ctx => {
+ if luoi-o { luoi(ctx) }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ // tiệm cận + nhãn trên trục
+ doan(ctx, (x0, cs.ymin), (x0, cs.ymax), mau: red, day: 0.9pt, dut: true)
+ doan(ctx, (cs.xmin, y0), (cs.xmax, y0), mau: red, day: 0.9pt, dut: true)
+ if calc.abs(x0) > 0.001 { nhan(ctx, (x0, 0), so-can-thuc(-d, 0, 0, c), huong: huong-x0, cach: 4pt) }
+ if calc.abs(y0) > 0.001 { nhan(ctx, (0, y0), so-can-thuc(a, 0, 0, c), huong: huong-y0, cach: 4pt) }
+ // hai nhánh
+ let g = (cs.xmax - cs.xmin) * 0.012
+ ve-ham(ctx, f, tu: cs.xmin, den: x0 - g, n: 130, mau: mau)
+ ve-ham(ctx, f, tu: x0 + g, den: cs.xmax, n: 130, mau: mau)
+ if a != 0 { _nhan-giao-ox(ctx, (-b / a,), giao-ox) }
+ if d != 0 { _nhan-giao-oy(ctx, b / d, giao-oy) }
+ _nhan-ten(ctx, ten, goc-ten, mau)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Hữu tỉ bậc hai/bậc nhất: y = (ax² + bx + c)/(dx + e) ----------
+// Tự nhận 4 tình huống: (a/d > 0 hay < 0) × (có 2 cực trị hay không).
+// Có cực trị -> cửa sổ bao 2 điểm cực trị (+ gióng nhãn).
+// Không cực trị -> cửa sổ quanh tâm đối xứng (giao 2 tiệm cận).
+// LUÔN vẽ tiệm cận đứng x = x0 (nhãn trên Ox) và tiệm cận xiên
+// y = px + q (nhãn ten-tcx: auto = "y = px + q" | none | nội dung tuỳ ý).
+#let do-thi-huu-ti(
+ a, b, c, d, e,
+ w: 7.6cm, mau: blue, ten: none, goc-ten: "above-right",
+ dan-x: 1, dan-y: 1,
+ cuc-tri: auto,
+ tam: auto, // gióng tâm đối xứng I(x0, px0+q) + nhãn:
+ // auto = chỉ khi không có cực trị | true/(x:..,y:..) = luôn | none = tắt
+ giao-ox: none, giao-oy: none,
+ huong-x0: "below-right", // hướng nhãn tiệm cận đứng trên Ox
+ ten-tcx: auto, huong-tcx: "above-left", // nhãn tiệm cận xiên
+ luoi-o: false, vach: false,
+ them: none,
+) = {
+ let x0 = -e / d // tiệm cận đứng
+ let p = a / d // tiệm cận xiên y = px + q
+ let q = (b * d - a * e) / (d * d)
+ let r = c - q * e // y = px + q + r/(dx + e)
+ let f = x => (a * x * x + b * x + c) / (d * x + e)
+ let tcx = x => p * x + q
+ let cuc-tri = _chuan-tc(cuc-tri)
+ let co-ct = p != 0 and r * d * p > 0
+ let hien-tam = if tam == none { false } else if tam == auto { not co-ct } else { true }
+ let (xt1, xt2) = (none, none)
+ let chot = ((x0, tcx(x0)),)
+ if co-ct {
+ let t = calc.sqrt(r * d / p)
+ let xs = ((-e - t) / d, (-e + t) / d).sorted()
+ xt1 = xs.at(0)
+ xt2 = xs.at(1)
+ let s = xt2 - xt1
+ chot = chot + (
+ (xt1, f(xt1)), (xt2, f(xt2)),
+ (xt1 - 0.55 * s, f(xt1 - 0.55 * s)), (xt2 + 0.55 * s, f(xt2 + 0.55 * s)),
+ )
+ } else {
+ // không cực trị: mở quanh tâm sao cho nhánh tách rõ khỏi tiệm cận
+ let t = calc.max(calc.min(calc.abs(r / (2 * d)), 3), 0.9)
+ chot = chot + (
+ (x0 - t, f(x0 - t)), (x0 + t, f(x0 + t)),
+ (x0 - 2.4 * t, f(x0 - 2.4 * t)), (x0 + 2.4 * t, f(x0 + 2.4 * t)),
+ )
+ }
+ // giao Ox: nghiệm tử thức (nếu cần nhãn)
+ let ng = if giao-ox != none and b * b - 4 * a * c > 0 {
+ let s = calc.sqrt(b * b - 4 * a * c) / (2 * calc.abs(a))
+ (-b / (2 * a) - s, -b / (2 * a) + s)
+ } else { () }
+ let cs = _dan(_cua-so(chot + ng.map(x => (x, 0)), le: 0.14), dan-x, dan-y)
+ hinh(
+ w: w, h: w * 0.78,
+ xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax,
+ ctx => {
+ if luoi-o { luoi(ctx) }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ // tiệm cận đứng (khi gióng tâm thì nhãn x0 do gióng đảm nhiệm, tránh ghi 2 lần)
+ doan(ctx, (x0, cs.ymin), (x0, cs.ymax), mau: red, day: 0.9pt, dut: true)
+ if calc.abs(x0) > 0.001 and not hien-tam { nhan(ctx, (x0, 0), so-can-thuc(-e, 0, 0, d), huong: huong-x0, cach: 4pt) }
+ // tiệm cận xiên (cắt gọn trong cửa sổ)
+ let (X1, X2) = if calc.abs(p) > 0.0001 {
+ let ca = ((cs.ymin - q) / p, (cs.ymax - q) / p).sorted()
+ (calc.max(cs.xmin, ca.at(0)), calc.min(cs.xmax, ca.at(1)))
+ } else { (cs.xmin, cs.xmax) }
+ doan(ctx, (X1, tcx(X1)), (X2, tcx(X2)), mau: red, day: 0.9pt, dut: true)
+ if ten-tcx != none {
+ let nd = if ten-tcx == auto { _pt-bac-nhat(p, q) } else { ten-tcx }
+ let Xn = X1 + 0.88 * (X2 - X1)
+ nhan(ctx, (Xn, tcx(Xn)), nd, huong: huong-tcx, cach: 5pt, mau: red)
+ }
+ // hai nhánh
+ let g = (cs.xmax - cs.xmin) * 0.012
+ ve-ham(ctx, f, tu: cs.xmin, den: x0 - g, n: 140, mau: mau)
+ ve-ham(ctx, f, tu: x0 + g, den: cs.xmax, n: 140, mau: mau)
+ // cực trị (nếu có)
+ if xt1 != none {
+ let ct = cuc-tri-huu-ti(a, b, c, d, e)
+ _giong-ct(ctx, (xt1, f(xt1)), cuc-tri, 0, ten-x: ct.x1, ten-y: ct.y1)
+ _giong-ct(ctx, (xt2, f(xt2)), cuc-tri, 1, ten-x: ct.x2, ten-y: ct.y2)
+ }
+ // tâm đối xứng I = giao 2 tiệm cận
+ if hien-tam {
+ let o = if type(tam) == dictionary { tam } else { (:) }
+ giong(
+ ctx, (x0, tcx(x0)),
+ huong-x: o.at("x", default: "below"),
+ huong-y: o.at("y", default: "left"),
+ ten-x: o.at("ten-x", default: so-can-thuc(-e, 0, 0, d)),
+ ten-y: o.at("ten-y", default: so-can-thuc(b * d - 2 * a * e, 0, 0, d * d)),
+ )
+ }
+ _nhan-giao-ox(
+ ctx, ng, giao-ox,
+ ten: if ng.len() == 2 {
+ let dl = b * b - 4 * a * c
+ let sg = if a > 0 { 1 } else { -1 }
+ (so-can-thuc(-b, -sg, dl, 2 * a), so-can-thuc(-b, sg, dl, 2 * a))
+ } else { () },
+ )
+ if e != 0 { _nhan-giao-oy(ctx, c / e, giao-oy) }
+ _nhan-ten(ctx, ten, goc-ten, mau)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Mũ: y = aˣ (a > 0, a ≠ 1) ----------
+#let do-thi-mu(a, w: 7cm, mau: blue, ten: auto, goc-ten: auto, diem-db: true, luoi-o: false, vach: false, them: none) = {
+ // cơ số: nguyên -> số nguyên; còn lại -> số đẹp (phân số/căn) trong ngoặc
+ let cs = if calc.abs(a - calc.round(a)) < 0.001 { $#int(calc.round(a))$ } else { $(#so-toan(a))$ }
+ let nd = if ten == auto { $y = #cs^x$ } else { ten }
+ // a > 1 tăng (góc trống trên-trái) · a < 1 giảm (trên-phải)
+ let gt = if goc-ten == auto { if a > 1 { "above-left" } else { "above-right" } } else { goc-ten }
+ do-thi-ham(
+ x => calc.pow(a, x),
+ w: w, xmin: -2.9, xmax: 2.9, ymin: -1.2, ymax: 5.2,
+ mau: mau, ten: nd, goc-ten: gt, n: 160, luoi-o: luoi-o, vach: vach,
+ them: ctx => {
+ if diem-db {
+ giong(ctx, (0, 1), ten-x: none)
+ giong(ctx, (1, a))
+ }
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Lôgarit: y = log_a(x) ----------
+#let do-thi-log(a, w: 7cm, mau: blue, ten: auto, goc-ten: auto, diem-db: true, luoi-o: false, vach: false, them: none) = {
+ let cs = if calc.abs(a - calc.round(a)) < 0.001 { $#int(calc.round(a))$ } else { so-toan(a) }
+ let nd = if ten == auto { $y = log_#cs x$ } else { ten }
+ // a > 1 tăng (góc trống dưới-phải) · a < 1 giảm (dưới-trái)
+ let gt = if goc-ten == auto { if a > 1 { "below-right" } else { "below-left" } } else { goc-ten }
+ do-thi-ham(
+ x => calc.ln(x) / calc.ln(a),
+ w: w, xmin: -1.4, xmax: 6, ymin: -3.1, ymax: 3.1,
+ tu: 0.035, den: 5.8,
+ mau: mau, ten: nd, goc-ten: gt, n: 220, luoi-o: luoi-o, vach: vach,
+ them: ctx => {
+ if diem-db {
+ giong(ctx, (1, 0), ten-y: none)
+ giong(ctx, (a, 1))
+ }
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Lượng giác ----------
+// Ghi nhãn các bội của π trên trục hoành.
+#let nhan-pi(ctx) = {
+ let cap = (
+ (-2 * calc.pi, $-2pi$), (-calc.pi, $-pi$), (-calc.pi / 2, $-pi/2$),
+ (calc.pi / 2, $pi/2$), (calc.pi, $pi$), (2 * calc.pi, $2pi$),
+ )
+ for (v, t) in cap {
+ if v > ctx.xmin + 0.3 and v < ctx.xmax - 0.3 {
+ let s = 3pt / ctx.sy
+ doan(ctx, (v, -s), (v, s), day: 0.8pt)
+ nhan(ctx, (v, 0), t, huong: "below", cach: 5pt)
+ }
+ }
+}
+
+#let do-thi-sin(w: 10cm, mau: blue, ten: $y = sin x$, goc-ten: "above-right", luoi-o: false, them: none) = do-thi-ham(
+ x => calc.sin(x),
+ w: w, h: w * 0.34,
+ xmin: -6.9, xmax: 6.9, ymin: -1.7, ymax: 1.7,
+ tu: -6.6, den: 6.6,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 220, luoi-o: luoi-o,
+ them: ctx => {
+ nhan-pi(ctx)
+ nhan(ctx, (0, 1), $1$, huong: "above-left", cach: 3pt)
+ nhan(ctx, (0, -1), $-1$, huong: "below-left", cach: 3pt)
+ if them != none { them(ctx) }
+ },
+)
+
+#let do-thi-cos(w: 10cm, mau: blue, ten: $y = cos x$, goc-ten: "above-right", luoi-o: false, them: none) = do-thi-ham(
+ x => calc.cos(x),
+ w: w, h: w * 0.34,
+ xmin: -6.9, xmax: 6.9, ymin: -1.7, ymax: 1.7,
+ tu: -6.6, den: 6.6,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 220, luoi-o: luoi-o,
+ them: ctx => {
+ nhan-pi(ctx)
+ nhan(ctx, (0, 1), $1$, huong: "above-right", cach: 3pt)
+ if them != none { them(ctx) }
+ },
+)
+
+#let do-thi-tan(w: 8cm, mau: blue, ten: $y = tan x$, goc-ten: "above-right", luoi-o: false, them: none) = {
+ let p2 = calc.pi / 2
+ do-thi-ham(
+ x => calc.tan(x),
+ w: w, h: w * 0.8,
+ xmin: -4.6, xmax: 4.6, ymin: -3.6, ymax: 3.6,
+ tu: -4.55, den: 4.55,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 260, luoi-o: luoi-o,
+ them: ctx => {
+ for k in (-3, -1, 1, 3) {
+ doan(ctx, (k * p2, -3.6), (k * p2, 3.6), mau: red, day: 0.8pt, dut: true)
+ }
+ nhan-pi(ctx)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- Côtang: y = cot x (tiệm cận đứng x = kπ) ----------
+// cot x = cos x / sin x; nhánh tự tách tại các tiệm cận (giống tan).
+#let do-thi-cot(w: 8cm, mau: blue, ten: $y = cot x$, goc-ten: "above-left", luoi-o: false, them: none) = {
+ let pi = calc.pi
+ let cot = x => {
+ let s = calc.sin(x)
+ if calc.abs(s) < 0.000001 { 1000000 } else { calc.cos(x) / s }
+ }
+ do-thi-ham(
+ cot,
+ w: w, h: w * 0.8,
+ xmin: -4.6, xmax: 4.6, ymin: -3.6, ymax: 3.6,
+ tu: -4.55, den: 4.55,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 260, luoi-o: luoi-o,
+ them: ctx => {
+ // tiệm cận x = ±π (x = 0 trùng trục Oy nên không vẽ lại)
+ for k in (-1, 1) {
+ doan(ctx, (k * pi, -3.6), (k * pi, 3.6), mau: red, day: 0.8pt, dut: true)
+ }
+ nhan-pi(ctx)
+ if them != none { them(ctx) }
+ },
+ )
+}
+
+// ---------- VẼ NHANH: chỉ cần công thức + màu ----------
+// Tự lấy mẫu hàm số và chọn cửa sổ y hợp lý (kể cả khi hàm tăng rất nhanh).
+// #ve-do-thi(x => x*x*x - 3*x, mau: red)
+// #ve-do-thi(x => calc.sin(2*x), mau: purple, ten: $y = sin 2x$)
+// Lưu ý: hàm phải xác định trên [xmin, xmax] (hoặc trên [tu, den] nếu đặt);
+// với hàm phân thức có tiệm cận, dùng #do-thi-phan-thuc / #do-thi-huu-ti.
+#let ve-do-thi(
+ f,
+ mau: blue,
+ w: 8cm,
+ xmin: -4, xmax: 4,
+ tu: auto, den: auto,
+ ten: none, goc-ten: "above-right",
+ vach: false, luoi-o: false,
+ gioi-han-y: 12, // bề cao tối đa của cửa sổ nhìn theo trục y
+ giao-ox: none, giao-oy: none,
+ day: 1.4pt,
+ them: none,
+) = {
+ let a = if tu == auto { xmin } else { tu }
+ let b = if den == auto { xmax } else { den }
+ let ys = lay-mau(f, a, b, n: 160).map(p => p.at(1)).sorted()
+ let lo = ys.first()
+ let hi = ys.last()
+ // gộp trục hoành vào cửa sổ nếu không làm cửa sổ quá cao
+ if lo > 0 and hi <= gioi-han-y { lo = 0 }
+ if hi < 0 and -lo <= gioi-han-y { hi = 0 }
+ // hàm biến thiên quá mạnh: thu cửa sổ quanh trung vị (đồ thị tự cắt nhánh)
+ if hi - lo > gioi-han-y {
+ let tv = ys.at(calc.floor(ys.len() / 2))
+ lo = calc.max(lo, tv - gioi-han-y / 2)
+ hi = calc.min(hi, tv + gioi-han-y / 2)
+ }
+ let dem = (hi - lo) * 0.1 + 0.35
+ do-thi-ham(
+ f,
+ w: w, xmin: xmin, xmax: xmax,
+ ymin: lo - dem, ymax: hi + dem,
+ tu: a, den: b,
+ mau: mau, day: day, n: 180,
+ ten: ten, goc-ten: goc-ten, vach: vach, luoi-o: luoi-o,
+ them: them,
+ )
+}
+
+// =====================================================================
+// NHIỀU ĐỒ THỊ TRÊN CÙNG MỘT HỆ TRỤC
+// Mỗi hàm gói bằng #ham(...); truyền hàm trần x => ... cũng được.
+// #do-thi-nhieu-ham(
+// ham(x => x*x - 2, mau: blue, ten: $y = x^2 - 2$),
+// ham(x => x, mau: red, ten: $y = x$, dut: true),
+// xmin: -3, xmax: 3, giao-diem: auto,
+// )
+// =====================================================================
+// Gói thông số một hàm.
+// tai : auto = nhãn đặt gần đầu phải của đường; hoặc hoành độ x cụ thể
+// huong-ten : hướng nhãn so với điểm đặt
+#let ham(
+ f,
+ mau: blue, day: 1.3pt, dut: false,
+ ten: none, tai: auto, huong-ten: "above",
+ tu: auto, den: auto,
+) = (
+ f: f, mau: mau, day: day, dut: dut,
+ ten: ten, tai: tai, huong-ten: huong-ten, tu: tu, den: den,
+)
+
+#let do-thi-nhieu-ham(
+ ..cac-ham,
+ w: 8cm, h: auto,
+ xmin: -4, xmax: 4, ymin: -3, ymax: 3,
+ vach: false, luoi-o: false,
+ giao-diem: none, // auto = chấm đỏ tại giao điểm của từng cặp đồ thị
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let ds = cac-ham.pos().map(hs => if type(hs) == function { ham(hs) } else { hs })
+ let cs = _dan((xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax), dan-x, dan-y)
+ let hh = if h == auto { w * 0.68 } else { h }
+ hinh(w: w, h: hh, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax, ctx => {
+ if luoi-o { luoi(ctx) }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ for hs in ds {
+ let a = if hs.tu == auto { cs.xmin } else { hs.tu }
+ let b = if hs.den == auto { cs.xmax } else { hs.den }
+ ve-ham(ctx, hs.f, tu: a, den: b, mau: hs.mau, day: hs.day, dut: hs.dut)
+ if hs.ten != none {
+ // vị trí nhãn: hoành độ chỉ định, hoặc điểm trong cửa sổ gần đầu phải
+ let P = none
+ if hs.tai != auto {
+ P = (hs.tai, (hs.f)(hs.tai))
+ } else {
+ let le = (cs.ymax - cs.ymin) * 0.08
+ let i = 40
+ while i >= 0 and P == none {
+ let x = a + (b - a) * (0.06 + 0.88 * i / 40)
+ let y = (hs.f)(x)
+ if y > cs.ymin + le and y < cs.ymax - le { P = (x, y) }
+ i = i - 1
+ }
+ }
+ if P != none { nhan(ctx, P, hs.ten, huong: hs.huong-ten, mau: hs.mau, cach: 7pt) }
+ }
+ }
+ if giao-diem == auto {
+ for i in range(ds.len()) {
+ for j in range(i + 1, ds.len()) {
+ let fi = ds.at(i).f
+ let fj = ds.at(j).f
+ for x in _nghiem(x => fi(x) - fj(x), cs.xmin, cs.xmax) {
+ let y = fi(x)
+ if y > cs.ymin and y < cs.ymax { diem(ctx, (x, y), bk: 2pt, mau: red) }
+ }
+ }
+ }
+ }
+ if them != none { them(ctx) }
+ })
+}
+
+// =====================================================================
+// MIỀN NGHIỆM BPT / HỆ BPT BẬC NHẤT HAI ẨN
+// Quy ước SGK: GẠCH phần KHÔNG là miền nghiệm; miền nghiệm để trắng
+// (hoặc tô nhạt bằng to-mien).
+// #mien-nghiem(bpt(4, 5, -8, dau: "<"), xmin: -4, xmax: 4)
+// #mien-nghiem(
+// bpt(3, -2, -9, dau: "<=", mau: red),
+// bpt(3, -5, -18, dau: ">=", mau: green.darken(20%)),
+// to-mien: rgb(30, 100, 200, 40),
+// )
+// =====================================================================
+// Một BPT: a·x + b·y (dau) c. dau: "<=", ">=", "<", ">"
+// mau : màu đường thẳng; mau-gach: auto = cùng màu (nhạt hơn)
+// goc-gach : GÓC gạch chéo RIÊNG cho bpt này; auto = theo goc-gach chung
+// của mien-nghiem (giống lối mau-gach). buoc-gach: bước gạch riêng
+// ten : nhãn đường thẳng (vd $d: 4x + 5y = -8$); ten-tai: 0..1 vị trí
+// dọc theo đoạn nhìn thấy tại điểm đặt nhãn
+// nghieng-ten : true (mặc định) = chữ NẰM NGHIÊNG theo chiều đường thẳng
+// (tự đọc xuôi, không lộn ngược); false = chữ nằm ngang như cũ
+// huong-ten : PHÍA đặt nhãn tại điểm đó — "above" hoặc "below". Khi nghiêng,
+// đây là phía trên/dưới so với đường (theo pháp tuyến); khi không
+// nghiêng vẫn nhận mọi hướng cũ ("above"/"below"/"left"/tuple…)
+// cach-ten : khoảng cách từ đường tới nhãn
+#let bpt(
+ a, b, c,
+ dau: "<=",
+ mau: red, day: 1.1pt,
+ mau-gach: auto, goc-gach: auto, buoc-gach: auto,
+ ten: none, ten-tai: 0.78, huong-ten: "above",
+ nghieng-ten: true, cach-ten: 3pt,
+) = (
+ a: a, b: b, c: c, dau: dau, mau: mau, day: day,
+ mau-gach: mau-gach, goc-gach: goc-gach, buoc-gach: buoc-gach,
+ ten: ten, ten-tai: ten-tai, huong-ten: huong-ten,
+ nghieng-ten: nghieng-ten, cach-ten: cach-ten,
+)
+
+// Đoạn nhìn thấy của đường a·x + b·y = c trong cửa sổ (none nếu ngoài).
+#let _doan-cua-so(ctx, a, b, c) = {
+ let gd = ()
+ let canh = (
+ ((ctx.xmin, ctx.ymin), (ctx.xmax, ctx.ymin)),
+ ((ctx.xmax, ctx.ymin), (ctx.xmax, ctx.ymax)),
+ ((ctx.xmax, ctx.ymax), (ctx.xmin, ctx.ymax)),
+ ((ctx.xmin, ctx.ymax), (ctx.xmin, ctx.ymin)),
+ )
+ for (P, Q) in canh {
+ let fp = a * P.at(0) + b * P.at(1) - c
+ let fq = a * Q.at(0) + b * Q.at(1) - c
+ if fp * fq <= 0 and fp != fq {
+ let s = fp / (fp - fq)
+ let G = (P.at(0) + s * (Q.at(0) - P.at(0)), P.at(1) + s * (Q.at(1) - P.at(1)))
+ if gd.len() == 0 or khoang-cach(gd.at(0), G) > 0.0001 { gd.push(G) }
+ }
+ }
+ if gd.len() >= 2 { (gd.at(0), gd.at(1)) } else { none }
+}
+
+#let mien-nghiem(
+ ..cac-bpt,
+ w: 7cm, h: auto,
+ xmin: -5, xmax: 5, ymin: -4, ymax: 4,
+ to-mien: none, // màu tô miền nghiệm (nên trong suốt), none = bỏ
+ giao-truc: none, // auto = ghi số giao điểm của các đường với 2 trục
+ buoc-gach: 6.5pt, goc-gach: 45deg,
+ vach: false, luoi-o: false,
+ them: none,
+) = {
+ let ds = cac-bpt.pos()
+ let hh = if h == auto { w * (ymax - ymin) / (xmax - xmin) } else { h }
+ hinh(w: w, h: hh, xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax, ctx => {
+ let khung = ((xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax))
+ if luoi-o { luoi(ctx) }
+ // tô miền nghiệm: giao các nửa mặt phẳng
+ if to-mien != none {
+ let mn = khung
+ for d in ds {
+ // chuẩn hoá về dạng ≤: miền nghiệm là a·x + b·y ≤ c
+ let (a, b, c) = if d.dau in ("<=", "<") { (d.a, d.b, d.c) } else { (-d.a, -d.b, -d.c) }
+ mn = cat-nua-mp(mn, a, b, c)
+ }
+ if mn.len() >= 3 { da-giac-pt(mn.map(P => toa-pt(ctx, P)), to: to-mien) }
+ }
+ // gạch phần loại bỏ của từng BPT
+ for d in ds {
+ let (a, b, c) = if d.dau in ("<=", "<") { (d.a, d.b, d.c) } else { (-d.a, -d.b, -d.c) }
+ let loai = cat-nua-mp(khung, -a, -b, -c) // nửa mặt phẳng a·x + b·y ≥ c
+ let mg = if d.mau-gach == auto { d.mau.lighten(25%) } else { d.mau-gach }
+ // góc/bước gạch RIÊNG của từng bpt (auto = theo giá trị chung của mien-nghiem)
+ let gg = if d.at("goc-gach", default: auto) == auto { goc-gach } else { d.goc-gach }
+ let bg = if d.at("buoc-gach", default: auto) == auto { buoc-gach } else { d.buoc-gach }
+ if loai.len() >= 3 {
+ gach-mien(ctx, loai, goc: gg, buoc: bg, mau: mg)
+ }
+ }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ // đường biên (đứt nếu BPT ngặt) + nhãn
+ for d in ds {
+ let dc = _doan-cua-so(ctx, d.a, d.b, d.c)
+ if dc != none {
+ let (A, B) = dc
+ doan(ctx, A, B, mau: d.mau, day: d.day, dut: d.dau in ("<", ">"))
+ if d.ten != none {
+ let P = chia(A, B, d.ten-tai)
+ let cach = d.at("cach-ten", default: 3pt)
+ if d.at("nghieng-ten", default: true) {
+ // chữ nằm nghiêng theo chiều đường; PHÍA above/below theo pháp tuyến.
+ // Đặt SÁT đường: lệch = cach + nửa bề DÀY chữ (vuông góc đường) —
+ // KHÔNG dùng offset của `nhan` (offset đó theo nửa khung ĐÃ XOAY, với
+ // nhãn dài + đường xiên thì khung rất to nên nhãn bị đẩy xa đường).
+ let xuoi = toa-pt(ctx, A).at(0) <= toa-pt(ctx, B).at(0)
+ let ang = if xuoi { goc-truc(ctx, A, B) } else { goc-truc(ctx, B, A) }
+ let n = _phap-tuyen(ctx, A, B) // pháp tuyến hướng LÊN (trang)
+ if d.huong-ten in ("below", "duoi") { n = (-n.at(0), -n.at(1)) }
+ context {
+ let nd = text(fill: d.mau, d.ten)
+ let nd2 = rotate(ang, reflow: true, nd)
+ let s = measure(nd2) // khung sau khi xoay (canh giữa)
+ let off = cach + measure(nd).height / 2 // bề dày chữ, vuông góc đường
+ let p = toa(ctx, P)
+ place(
+ dx: p.at(0) - s.width / 2 + n.at(0) * off,
+ dy: p.at(1) - s.height / 2 + n.at(1) * off,
+ nd2,
+ )
+ }
+ } else {
+ nhan(ctx, P, d.ten, huong: d.huong-ten, mau: d.mau, cach: cach)
+ }
+ }
+ }
+ if giao-truc == auto {
+ if d.a != 0 { _nhan-giao-ox(ctx, (d.c / d.a,), auto) }
+ if d.b != 0 { _nhan-giao-oy(ctx, d.c / d.b, auto) }
+ }
+ }
+ if them != none { them(ctx) }
+ })
+}
+
+// =====================================================================
+// DIỆN TÍCH HÌNH PHẲNG GIỚI HẠN BỞI HAI ĐỒ THỊ
+// g mặc định là trục hoành (y = 0).
+// a, b: auto = tự lấy giao điểm ngoài cùng của f và g trong [tu, den];
+// hoặc chỉ định đoạn [a, b] cụ thể.
+// Miền tô tự tách tại các giao điểm ở giữa nên tô đúng cả khi f − g đổi dấu.
+// #dien-tich-2-ham(x => x*x, g: x => x + 2) // tự tìm giao
+// #dien-tich-2-ham(x => calc.sin(x), a: 0, b: calc.pi) // trên [a, b]
+// =====================================================================
+// Điểm đặt nhãn: điểm trên đồ thị gần mép phải mà còn nằm trong cửa sổ.
+#let _diem-nhan(cs, f, a, b) = {
+ let le = (cs.ymax - cs.ymin) * 0.08
+ let i = 40
+ while i >= 0 {
+ let x = a + (b - a) * (0.06 + 0.88 * i / 40)
+ let y = f(x)
+ if y > cs.ymin + le and y < cs.ymax - le { return (x, y) }
+ i = i - 1
+ }
+ none
+}
+
+#let dien-tich-2-ham(
+ f, g: x => 0.0,
+ a: auto, b: auto,
+ tu: -5, den: 5, // phạm vi tìm giao điểm khi a, b: auto
+ w: 8cm, h: auto,
+ mau-f: blue, mau-g: red, day: 1.3pt,
+ ten-f: none, ten-g: none,
+ huong-ten-f: "above", huong-ten-g: "below",
+ mau-to: rgb(70, 90, 200, 70),
+ ten-a: auto, ten-b: auto, // nhãn cận trên Ox; none = bỏ
+ giong-ab: true, // đường đứt x = a, x = b
+ cham-giao: true, // chấm đỏ tại giao điểm
+ keo-dai: 0.22, // vẽ đồ thị dài thêm ra 2 phía (tỉ lệ đoạn)
+ dan-x: 1, dan-y: 1,
+ them: none,
+) = {
+ let hieu = x => f(x) - g(x)
+ let giao = _nghiem(hieu, tu, den)
+ let aa = if a == auto { if giao.len() > 0 { giao.first() } else { tu } } else { a }
+ let bb = if b == auto { if giao.len() > 0 { giao.last() } else { den } } else { b }
+ let m = (bb - aa) * keo-dai + 0.3
+ // cửa sổ: bao miền tô trên [aa, bb]; đồ thị ngoài đoạn bị cắt theo cửa sổ
+ let chot = lay-mau(f, aa, bb, n: 60) + lay-mau(g, aa, bb, n: 60)
+ let cs = _dan(_cua-so(chot + ((aa - m, 0), (bb + m, 0))), dan-x, dan-y)
+ let hh = if h == auto { w * 0.68 } else { h }
+ hinh(w: w, h: hh, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax, ctx => {
+ // các đoạn con tách tại giao điểm nằm trong (aa, bb)
+ let moc = (aa,) + giao.filter(x => x > aa + 0.0001 and x < bb - 0.0001) + (bb,)
+ for i in range(moc.len() - 1) {
+ to-vung-2-ham(ctx, f, g, moc.at(i), moc.at(i + 1), mau: mau-to)
+ }
+ truc(ctx)
+ ve-ham(ctx, f, tu: aa - m, den: bb + m, mau: mau-f, day: day)
+ ve-ham(ctx, g, tu: aa - m, den: bb + m, mau: mau-g, day: day)
+ if giong-ab {
+ for (x, t) in ((aa, ten-a), (bb, ten-b)) {
+ let (y1, y2) = (calc.min(f(x), g(x), 0), calc.max(f(x), g(x), 0))
+ if y2 - y1 > 0.0001 {
+ doan(ctx, (x, y1), (x, y2), mau: gray.darken(20%), day: 0.8pt, dut: true)
+ }
+ if t != none and calc.abs(x) > 0.001 {
+ nhan(ctx, (x, 0), if t == auto { so-toan(x) } else { t },
+ huong: if calc.max(f(x), g(x)) < 0.0001 { "above" } else { "below" }, cach: 4pt)
+ }
+ }
+ }
+ if cham-giao {
+ for x in giao.filter(x => x >= aa - 0.0001 and x <= bb + 0.0001) {
+ diem(ctx, (x, f(x)), bk: 2pt, mau: red)
+ }
+ }
+ if ten-f != none {
+ let P = _diem-nhan(cs, f, aa - m, bb + m)
+ if P != none { nhan(ctx, P, ten-f, huong: huong-ten-f, mau: mau-f, cach: 7pt) }
+ }
+ if ten-g != none {
+ let P = _diem-nhan(cs, g, aa - m, bb + m)
+ if P != none { nhan(ctx, P, ten-g, huong: huong-ten-g, mau: mau-g, cach: 7pt) }
+ }
+ if them != none { them(ctx) }
+ })
+}
+
+// ---------- Căn thức: y = √x ----------
+#let do-thi-can(w: 7cm, mau: blue, ten: $y = sqrt(x)$, goc-ten: "above-right", luoi-o: false, vach: false, them: none) = do-thi-ham(
+ x => calc.sqrt(x),
+ w: w, xmin: -1.2, xmax: 6.2, ymin: -1, ymax: 3.2,
+ tu: 0, den: 6,
+ mau: mau, ten: ten, goc-ten: goc-ten, n: 160, luoi-o: luoi-o, vach: vach,
+ them: ctx => {
+ giong(ctx, (1, 1))
+ giong(ctx, (4, 2))
+ if them != none { them(ctx) }
+ },
+)
+
+// =====================================================================
+// TRỤC SỐ — biểu diễn đoạn / khoảng / nửa khoảng (một hoặc nhiều)
+// Mỗi khoảng là tuple: (a, b, kieu) hoặc (a, b, kieu, mau)
+// a, b : số HOẶC "-oo" / "+oo" (vô cực) — a < b
+// kieu : chuỗi 2 kí tự đầu mút trái+phải, chọn trong:
+// "[]" đoạn · "()" khoảng · "[)" · "(]" nửa khoảng
+// Quy ước (SGK): GIỮ trống vùng nghiệm, GẠCH CHÉO phần loại bỏ;
+// [ ] = lấy đầu mút (đặc), ( ) = không lấy (rỗng).
+// #truc-so((-2, 3, "[)"))
+// #truc-so((-2, 1, "()"), (3, "+oo", "[)"))
+// dau: "ngoac" (mặc định) | "cham" (chấm đặc/rỗng)
+// =====================================================================
+#let _vc(v) = type(v) == str // v là vô cực ("-oo"/"+oo") ?
+
+#let truc-so(
+ ..khoang,
+ w: 10cm, h: 1.5cm, // h: chiều cao khung cố định (hết khoảng trống dọc)
+ min: auto, max: auto,
+ ten: $x$,
+ mau: rgb("#1d4ed8"), // màu đoạn nghiệm
+ gach: true, // gạch chéo phần loại bỏ
+ mau-gach: red,
+ dau: "ngoac", // kiểu đầu mút: "ngoac" | "cham"
+ moc-phu: (), // mốc phụ chỉ để ghi số (không thuộc nghiệm)
+ so: true,
+ co-chu: 13pt, // cỡ chữ số tại mốc
+ cao-gach: 2mm, // chiều cao dải gạch chéo (đo trên trang)
+) = {
+ let ks = khoang.pos()
+ // các đầu mút hữu hạn -> cửa sổ tự động
+ let huu-han = ()
+ for k in ks {
+ if not _vc(k.at(0)) { huu-han.push(k.at(0)) }
+ if not _vc(k.at(1)) { huu-han.push(k.at(1)) }
+ }
+ for m in moc-phu { huu-han.push(if type(m) == array { m.at(0) } else { m }) }
+ let lo = if huu-han.len() > 0 { calc.min(..huu-han) } else { -4 }
+ let hi = if huu-han.len() > 0 { calc.max(..huu-han) } else { 4 }
+ let pad = calc.max((hi - lo) * 0.2, 1.5)
+ let xa = if min == auto { lo - pad } else { min }
+ let xb = if max == auto { hi + pad } else { max }
+ hinh(w: w, h: h, xmin: xa, xmax: xb, ymin: -1.1, ymax: 1.1, co-chu: co-chu, ctx => {
+ // ---- gạch chéo phần loại bỏ (bù của hợp các khoảng) ----
+ if gach {
+ let segs = ks.map(k => {
+ let a = if _vc(k.at(0)) { xa } else { k.at(0) }
+ let b = if _vc(k.at(1)) { xb } else { k.at(1) }
+ (calc.min(a, b), calc.max(a, b))
+ }).sorted(key: s => s.at(0))
+ let merged = ()
+ for s in segs {
+ if merged.len() == 0 or s.at(0) > merged.last().at(1) + 0.000001 {
+ merged.push(s)
+ } else {
+ let last = merged.pop()
+ merged.push((last.at(0), calc.max(last.at(1), s.at(1))))
+ }
+ }
+ let unc = ()
+ let cur = xa
+ for s in merged {
+ if s.at(0) > cur + 0.000001 { unc.push((cur, s.at(0))) }
+ cur = calc.max(cur, s.at(1))
+ }
+ if cur < xb - 0.000001 { unc.push((cur, xb)) }
+ // dải gạch cao CỐ ĐỊNH trên trang (không phình theo cửa sổ toạ độ)
+ let hb = cao-gach / 2 / ctx.sy
+ for (u1, u2) in unc {
+ gach-mien(
+ ctx, ((u1, -hb), (u2, -hb), (u2, hb), (u1, hb)),
+ goc: 45deg, buoc: 6pt, mau: mau-gach, day: 0.5pt,
+ )
+ }
+ }
+ // ---- trục (mũi tên 2 đầu) + nhãn x ----
+ doan(ctx, (xa, 0), (xb, 0), day: 1pt)
+ dau-mui-ten(ctx, (xa, 0), (xb, 0))
+ dau-mui-ten(ctx, (xb, 0), (xa, 0))
+ nhan(ctx, (xb, 0), ten, huong: "below", cach: 6pt)
+ // ---- vạch + số tại đầu mút và mốc phụ ----
+ let t = 3pt / ctx.sy
+ let ghi-moc(x) = {
+ doan(ctx, (x, -t), (x, t), day: 0.9pt)
+ if so { nhan(ctx, (x, 0), so-toan(x), huong: "below", cach: 6pt) }
+ }
+ for k in ks {
+ if not _vc(k.at(0)) { ghi-moc(k.at(0)) }
+ if not _vc(k.at(1)) { ghi-moc(k.at(1)) }
+ }
+ for m in moc-phu { ghi-moc(if type(m) == array { m.at(0) } else { m }) }
+ // ---- đoạn nghiệm + đầu mút ----
+ let dau-mut(x, mo, ben) = {
+ // ben: "trai" | "phai" (quyết định chiều ngoặc / hướng)
+ let m = mau
+ if dau == "cham" {
+ let p = toa(ctx, (x, 0))
+ if mo {
+ place(dx: p.at(0) - 3pt, dy: p.at(1) - 3pt,
+ circle(radius: 3pt, fill: white, stroke: 1pt + m))
+ } else {
+ place(dx: p.at(0) - 3pt, dy: p.at(1) - 3pt,
+ circle(radius: 3pt, fill: m, stroke: none))
+ }
+ } else {
+ let sym = if ben == "trai" { if mo { "(" } else { "[" } }
+ else { if mo { ")" } else { "]" } }
+ nhan(ctx, (x, 0), text(size: 1.45em, weight: "bold", fill: m)[#sym], huong: "center")
+ }
+ }
+ for k in ks {
+ let (a, b, kieu) = (k.at(0), k.at(1), k.at(2))
+ let m = if k.len() >= 4 { k.at(3) } else { mau }
+ let xl = if _vc(a) { xa } else { a }
+ let xr = if _vc(b) { xb } else { b }
+ doan(ctx, (xl, 0), (xr, 0), mau: m, day: 2.6pt)
+ if not _vc(a) {
+ let mo = kieu.at(0) == "("
+ if dau == "cham" { dau-mut(a, mo, "trai") }
+ else {
+ let sym = if mo { "(" } else { "[" }
+ nhan(ctx, (a, 0), text(size: 1.45em, weight: "bold", fill: m)[#sym], huong: "center")
+ }
+ }
+ if not _vc(b) {
+ let mo = kieu.at(1) == ")"
+ if dau == "cham" { dau-mut(b, mo, "phai") }
+ else {
+ let sym = if mo { ")" } else { "]" }
+ nhan(ctx, (b, 0), text(size: 1.45em, weight: "bold", fill: m)[#sym], huong: "center")
+ }
+ }
+ }
+ })
+}
+
+// =====================================================================
+// HYPERBOL x²/a² − y²/b² = 1 (nhập bán trục thực a, ảo b)
+// Mặc định vẽ kèm: 2 tiệm cận y = ±(b/a)x, đỉnh A₁A₂, tiêu điểm F₁F₂.
+// =====================================================================
+#let hyperbol(
+ a, b,
+ w: 8.4cm,
+ mau: blue, day: 1.4pt,
+ ten: auto, goc-ten: auto,
+ tiem-can: true, dinh: true, tieu-diem: true,
+ ten-dinh: ($A_1$, $A_2$), ten-tieu: ($F_1$, $F_2$),
+ luoi-o: false, vach: false,
+ them: none,
+) = {
+ let eexp = t => calc.pow(2.718281828459045, t)
+ let ch = t => (eexp(t) + eexp(-t)) / 2
+ let sh = t => (eexp(t) - eexp(-t)) / 2
+ let c = calc.sqrt(a * a + b * b)
+ let X = calc.max(a * 2.4, c * 1.12)
+ let Y = (b / a) * X * 0.9
+ let T = calc.ln(X / a + calc.sqrt((X / a) * (X / a) - 1))
+ let nhanh = s => range(0, 121).map(i => {
+ let tt = -T + 2 * T * i / 120
+ (s * a * ch(tt), b * sh(tt))
+ })
+ let nd = if ten == auto {
+ $x^2 / #so-dep(a * a) - y^2 / #so-dep(b * b) = 1$
+ } else { ten }
+ let gt = if goc-ten == auto { (X * 0.3, Y * 0.82) } else { goc-ten }
+ hinh(w: w, xmin: -X, xmax: X, ymin: -Y, ymax: Y, ctx => {
+ if luoi-o { luoi(ctx) }
+ if tiem-can {
+ let xl = Y * a / b
+ doan(ctx, (-xl, -Y), (xl, Y), mau: gray.darken(15%), day: 0.9pt, dut: true)
+ doan(ctx, (-xl, Y), (xl, -Y), mau: gray.darken(15%), day: 0.9pt, dut: true)
+ }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ duong-cong(ctx, nhanh(1), mau: mau, day: day)
+ duong-cong(ctx, nhanh(-1), mau: mau, day: day)
+ if dinh {
+ diem(ctx, (-a, 0), ten: ten-dinh.at(0), huong: "below", bk: 1.9pt, mau: red)
+ diem(ctx, (a, 0), ten: ten-dinh.at(1), huong: "below", bk: 1.9pt, mau: red)
+ }
+ if tieu-diem {
+ diem(ctx, (-c, 0), ten: ten-tieu.at(0), huong: "above", bk: 1.9pt)
+ diem(ctx, (c, 0), ten: ten-tieu.at(1), huong: "above", bk: 1.9pt)
+ }
+ _nhan-ten(ctx, nd, gt, mau)
+ if them != none { them(ctx) }
+ })
+}
+
+// =====================================================================
+// PARABOL y² = 2px (nhập tham số tiêu p ≠ 0; p > 0 mở phải, p < 0 mở trái)
+// Mặc định vẽ kèm: đỉnh O, tiêu điểm F(p/2; 0), đường chuẩn x = −p/2.
+// =====================================================================
+#let parabol(
+ p,
+ w: 8cm,
+ mau: blue, day: 1.4pt,
+ ten: auto, goc-ten: auto,
+ tieu-diem: true, duong-chuan: true, dinh: true,
+ ten-tieu: $F$,
+ luoi-o: false, vach: false,
+ them: none,
+) = {
+ let s = if p > 0 { 1 } else { -1 }
+ let Xt = 4.2
+ let Y = calc.sqrt(2 * calc.abs(p) * Xt)
+ let xdc = -p / 2 // đường chuẩn
+ let xf = p / 2 // tiêu điểm
+ let xs = (0, s * Xt, xdc, xf)
+ let xa = calc.min(..xs) - 0.9
+ let xb = calc.max(..xs) + 0.9
+ let ya = -Y - 0.6
+ let yb = Y + 0.6
+ let cur = range(0, 121).map(i => {
+ let yy = -Y + 2 * Y * i / 120
+ (yy * yy / (2 * p), yy)
+ })
+ let nd = if ten == auto { $y^2 = #so-dep(2 * p) x$ } else { ten }
+ // p > 0 mở phải -> nhãn bên trái trống; p < 0 mở trái -> nhãn bên phải
+ let gt = if goc-ten == auto { if s > 0 { "above-left" } else { "above-right" } } else { goc-ten }
+ hinh(w: w, xmin: xa, xmax: xb, ymin: ya, ymax: yb, ctx => {
+ if luoi-o { luoi(ctx) }
+ if duong-chuan {
+ doan(ctx, (xdc, ya + 0.2), (xdc, yb - 0.2), mau: gray.darken(15%), day: 1pt, dut: true)
+ nhan(ctx, (xdc, yb - 0.2), $Delta$, huong: if s > 0 { "above-left" } else { "above-right" }, cach: 3pt)
+ }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ duong-cong(ctx, cur, mau: mau, day: day)
+ if dinh { diem(ctx, (0, 0), bk: 1.9pt, mau: red) }
+ if tieu-diem {
+ diem(ctx, (xf, 0), ten: ten-tieu, huong: "below", bk: 1.9pt, mau: red)
+ }
+ _nhan-ten(ctx, nd, gt, mau)
+ if them != none { them(ctx) }
+ })
+}
+
+// =====================================================================
+// ELIP x²/a² + y²/b² = 1 (nhập 2 bán trục a, b như hyperbol)
+// Mặc định vẽ kèm: 4 đỉnh (±a; 0), (0; ±b) và 2 tiêu điểm trên trục lớn
+// (a > b: F(±c; 0); b > a: F(0; ±c); c = √|a² − b²|). a = b -> đường tròn.
+// =====================================================================
+#let duong-elip(
+ a, b,
+ w: 8cm,
+ mau: blue, day: 1.4pt,
+ ten: auto, goc-ten: "above-right",
+ dinh: true, tieu-diem: true,
+ ten-tieu: ($F_1$, $F_2$),
+ luoi-o: false, vach: false,
+ them: none,
+) = {
+ let c = calc.sqrt(calc.abs(a * a - b * b))
+ let mg = 0.85
+ let X = a + mg
+ let Y = b + mg
+ let nd = if ten == auto { $x^2 / #so-dep(a * a) + y^2 / #so-dep(b * b) = 1$ } else { ten }
+ hinh(w: w, xmin: -X, xmax: X, ymin: -Y, ymax: Y, ctx => {
+ if luoi-o { luoi(ctx) }
+ truc(ctx)
+ if vach { vach-chia(ctx) }
+ elip(ctx, (0, 0), a, b, mau: mau, day: day)
+ if dinh {
+ diem(ctx, (-a, 0), ten: $A_1$, huong: "below-left", bk: 1.9pt, mau: red)
+ diem(ctx, (a, 0), ten: $A_2$, huong: "below-right", bk: 1.9pt, mau: red)
+ diem(ctx, (0, b), ten: $B_2$, huong: "above-left", bk: 1.9pt, mau: red)
+ diem(ctx, (0, -b), ten: $B_1$, huong: "below-left", bk: 1.9pt, mau: red)
+ }
+ if tieu-diem and c > 0.001 {
+ if a >= b {
+ diem(ctx, (-c, 0), ten: ten-tieu.at(0), huong: "above", bk: 1.9pt)
+ diem(ctx, (c, 0), ten: ten-tieu.at(1), huong: "above", bk: 1.9pt)
+ } else {
+ diem(ctx, (0, -c), ten: ten-tieu.at(0), huong: "right", bk: 1.9pt)
+ diem(ctx, (0, c), ten: ten-tieu.at(1), huong: "right", bk: 1.9pt)
+ }
+ }
+ _nhan-ten(ctx, nd, goc-ten, mau)
+ if them != none { them(ctx) }
+ })
+}
+// (đồng bộ mount 07/2026)
diff --git a/packages/preview/conic-toan/0.3.4/lib/hinh-khong-gian.typ b/packages/preview/conic-toan/0.3.4/lib/hinh-khong-gian.typ
new file mode 100644
index 0000000000..5929960dd8
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/hinh-khong-gian.typ
@@ -0,0 +1,925 @@
+// =====================================================================
+// hinh-khong-gian.typ — HÌNH KHÔNG GIAN THPT
+// Hình chóp, lăng trụ, hình hộp, lập phương, nón, trụ, cầu, trục Oxyz.
+// Nét khuất tự động vẽ đứt theo đúng quy ước SGK.
+//
+// Mỗi hình đều có tham số `them`: hàm (ctx, dinh) => nội dung, cho phép
+// vẽ THÊM điểm/đoạn/thiết diện lên hình bằng chính các đỉnh có sẵn.
+// vd: them: (ctx, d) => { diem(ctx, chia(d.S, d.A, 0.5), ten: $M$) }
+// =====================================================================
+#import "ve.typ": *
+#import "hinh-phang.typ": huong-ra, trong-tam
+
+// Phép chiếu song song đơn giản: (x, y, z) -> mặt phẳng.
+// x: sang phải, y: chiều sâu (ra sau), z: lên trên.
+#let chieu(P, k: 0.45, goc: 40deg) = (
+ P.at(0) + k * P.at(1) * calc.cos(goc),
+ P.at(2) + k * P.at(1) * calc.sin(goc),
+)
+
+// Gọi callback `them` nếu có.
+#let goi-them(them, ctx, dinh) = {
+ if them != none { them(ctx, dinh) }
+}
+
+// ---------- HÌNH CHÓP TAM GIÁC S.ABC ----------
+// duong-cao: none | "tam" (chân H trong tam giác) | "dinh-a" (SA vuông đáy)
+#let hinh-chop-tam-giac(
+ w: 7cm,
+ ten: ($S$, $A$, $B$, $C$),
+ duong-cao: none,
+ ten-chan: $H$,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -0.6, xmax: 5.0, ymin: -0.5, ymax: 5.0, ctx => {
+ let d = (
+ A: (0.0, 1.1),
+ B: (3.1, 0.0),
+ C: (4.3, 1.9),
+ S: (2.1, 4.4),
+ )
+ // nét liền
+ doan(ctx, d.A, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.A, mau: mau, day: day)
+ doan(ctx, d.S, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.C, mau: mau, day: day)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ // nét khuất
+ doan(ctx, d.A, d.C, mau: mau, day: day, dut: true)
+ // đường cao
+ if duong-cao == "tam" {
+ let H = (2.35, 1.15)
+ d.insert("H", H)
+ doan(ctx, d.S, H, mau: mau-phu, day: 1pt, dut: true)
+ goc-vuong(ctx, H, d.S, d.B, r: 0.28, mau: mau-phu)
+ diem(ctx, H, ten: ten-chan, huong: "duoi", bk: 1.6pt, mau: mau-phu)
+ } else if duong-cao == "dinh-a" {
+ goc-vuong(ctx, d.A, d.S, d.B, r: 0.3, mau: mau-phu)
+ }
+ // nhãn đỉnh
+ let G = trong-tam(d.A, d.B, d.C, d.S)
+ for (P, t) in ((d.S, d.A, d.B, d.C).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH CHÓP TỨ GIÁC S.ABCD (đáy hình bình hành / vuông) ----------
+// duong-cheo: vẽ 2 đường chéo đáy (đứt) + tâm O.
+// duong-cao: none | "tam" (SO vuông đáy, chóp đều) | "dinh-a" (SA vuông đáy)
+
+#let hinh-chop-tu-giac-thuong(
+ w: 7.6cm,
+ ten: ($S$, $A$, $B$, $C$, $D$),
+ duong-cheo: false,
+ duong-cao: none,
+ ten-tam: $O$,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -1, xmax: 5, ymin: -0.5, ymax: 7, ctx => {
+ let d = (
+ A: (-1, 3),
+ B: (5, 3),
+ C: (4, 1),
+ D: (0, 0), // = A + C - B (hình bình hành)
+ S: (2, 7),
+ )
+ let O = trung-diem(d.A, d.C)
+ // nét liền
+ doan(ctx, d.A, d.B, mau: mau, day: day, dut: true)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.A, mau: mau, day: day)
+ doan(ctx, d.S, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.C, mau: mau, day: day)
+ // nét khuất
+ doan(ctx, d.A, d.D, mau: mau, day: day)
+ doan(ctx, d.D, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.D, mau: mau, day: day)
+ if duong-cheo or duong-cao == "tam" {
+ d.insert("O", O)
+ doan(ctx, d.A, d.C, mau: mau, day: 0.9pt, dut: true)
+ doan(ctx, d.B, d.D, mau: mau, day: 0.9pt, dut: true)
+ diem(ctx, O, ten: ten-tam, huong: "duoi", bk: 1.6pt)
+ }
+ if duong-cao == "tam" {
+ doan(ctx, d.S, O, mau: mau-phu, day: 1pt, dut: true)
+ goc-vuong(ctx, O, d.S, d.C, r: 0.3, mau: mau-phu)
+ } else if duong-cao == "dinh-a" {
+ goc-vuong(ctx, d.A, d.S, d.B, r: 0.3, mau: mau-phu)
+ }
+ // nhãn đỉnh
+ let G = trong-tam(d.A, d.B, d.C, d.D, d.S)
+ for (P, t) in ((d.S, d.A, d.B, d.C, d.D).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+#let hinh-chop-tu-giac(
+ w: 7.6cm,
+ ten: ($S$, $A$, $B$, $C$, $D$),
+ duong-cheo: false,
+ duong-cao: none,
+ ten-tam: $O$,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: 0, xmax: 9, ymin: -0.5, ymax: 6, ctx => {
+ let d = (
+ A: (3, 2),
+ B: (9, 2),
+ C: (6, 0),
+ D: (0, 0), // = A + C - B (hình bình hành)
+ S: (4.5, 6),
+ )
+ let O = trung-diem(d.A, d.C)
+ // nét liền
+ doan(ctx, d.A, d.B, mau: mau, day: day, dut: true)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.A, mau: mau, day: day, dut: true)
+ doan(ctx, d.S, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.C, mau: mau, day: day)
+ // nét khuất
+ doan(ctx, d.A, d.D, mau: mau, day: day, dut: true)
+ doan(ctx, d.D, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.D, mau: mau, day: day)
+ if duong-cheo or duong-cao == "tam" {
+ d.insert("O", O)
+ doan(ctx, d.A, d.C, mau: mau, day: 0.9pt, dut: true)
+ doan(ctx, d.B, d.D, mau: mau, day: 0.9pt, dut: true)
+ diem(ctx, O, ten: ten-tam, huong: "duoi", bk: 1.6pt)
+ }
+ if duong-cao == "tam" {
+ doan(ctx, d.S, O, mau: mau-phu, day: 1pt, dut: true)
+ goc-vuong(ctx, O, d.S, d.C, r: 0.3, mau: mau-phu)
+ } else if duong-cao == "dinh-a" {
+ goc-vuong(ctx, d.A, d.S, d.B, r: 0.3, mau: mau-phu)
+ }
+ // nhãn đỉnh
+ let G = trong-tam(d.A, d.B, d.C, d.D, d.S)
+ for (P, t) in ((d.S, d.A, d.B, d.C, d.D).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+#let hinh-chop-day-hinh-thang(
+ w: 7.6cm,
+ ten: ($S$, $A$, $B$, $C$, $D$),
+ duong-cheo: false,
+ duong-cao: none,
+ ten-tam: $O$,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: 0, xmax: 9, ymin: -0.5, ymax: 6, ctx => {
+ let d = (
+ A: (2, 2),
+ B: (9, 2),
+ C: (3.5, 0),
+ D: (0, 0), // = A + C - B (hình bình hành)
+ S: (2, 6),
+ )
+ let O = trung-diem(d.A, d.C)
+ // nét liền
+ doan(ctx, d.A, d.B, mau: mau, day: day, dut: true)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.A, mau: mau, day: day, dut: true)
+ doan(ctx, d.S, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.C, mau: mau, day: day)
+ // nét khuất
+ doan(ctx, d.A, d.D, mau: mau, day: day, dut: true)
+ doan(ctx, d.D, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.D, mau: mau, day: day)
+ if duong-cheo or duong-cao == "tam" {
+ d.insert("O", O)
+ doan(ctx, d.A, d.C, mau: mau, day: 0.9pt, dut: true)
+ doan(ctx, d.B, d.D, mau: mau, day: 0.9pt, dut: true)
+ diem(ctx, O, ten: ten-tam, huong: "duoi", bk: 1.6pt)
+ }
+ if duong-cao == "tam" {
+ doan(ctx, d.S, O, mau: mau-phu, day: 1pt, dut: true)
+ goc-vuong(ctx, O, d.S, d.C, r: 0.3, mau: mau-phu)
+ } else if duong-cao == "dinh-a" {
+ goc-vuong(ctx, d.A, d.S, d.B, r: 0.3, mau: mau-phu)
+ }
+ // nhãn đỉnh
+ let G = trong-tam(d.A, d.B, d.C, d.D, d.S)
+ for (P, t) in ((d.S, d.A, d.B, d.C, d.D).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH CHÓP TAM GIÁC ĐỀU S.ABC ----------
+// S thẳng trên trọng tâm O của đáy; SO vuông góc đáy (nét đứt).
+// trung-tuyen: vẽ thêm trung tuyến AM của đáy đi qua O.
+#let hinh-chop-tam-giac-deu(
+ w: 7cm,
+ ten: ($S$, $A$, $B$, $C$),
+ ten-tam: $O$,
+ ten-trung-diem: $M$, // tên trung điểm BC (khi trung-tuyen: true)
+ trung-tuyen: false,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -0.6, xmax: 6.0, ymin: -0.5, ymax: 5.3, ctx => {
+ let d = (A: (0.0, 2), B: (1.5, 0.0), C: (6, 2))
+ let O = trong-tam(d.A, d.B, d.C)
+ let S = (O.at(0), O.at(1) + 3.7)
+ d.insert("O", O)
+ d.insert("S", S)
+ // cạnh đáy
+ doan(ctx, d.A, d.B, mau: mau, day: day)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.A, d.C, mau: mau, day: day, dut: true)
+ // cạnh bên
+ doan(ctx, S, d.A, mau: mau, day: day)
+ doan(ctx, S, d.B, mau: mau, day: day)
+ doan(ctx, S, d.C, mau: mau, day: day)
+ // đường cao SO
+ if trung-tuyen {
+ let M = trung-diem(d.B, d.C)
+ d.insert("M", M)
+ doan(ctx, d.A, M, mau: mau, day: 0.9pt, dut: true)
+ diem(ctx, M, ten: ten-trung-diem, huong: "phai", bk: 1.5pt)
+ }
+ doan(ctx, S, O, mau: mau-phu, day: 1pt, dut: true)
+ goc-vuong(ctx, O, S, d.A, r: 0.26, mau: mau-phu)
+ diem(ctx, O, ten: ten-tam, huong: "duoi", bk: 1.5pt)
+ let G = trong-tam(d.A, d.B, d.C, S)
+ for (P, t) in ((S, d.A, d.B, d.C).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH CHÓP TỨ GIÁC ĐỀU S.ABCD ----------
+// (đáy hình vuông, SO vuông góc đáy tại tâm O — vẽ sẵn 2 đường chéo)
+#let hinh-chop-tu-giac-deu(w: 7.6cm, ten: ($S$, $A$, $B$, $C$, $D$), ten-tam: $O$, them: none) = {
+ hinh-chop-tu-giac(w: w, ten: ten, ten-tam: ten-tam, duong-cao: "tam", duong-cheo: true, them: them)
+}
+
+// ---------- HÌNH CHÓP TAM DIỆN VUÔNG O.ABC ----------
+// OA, OB, OC đôi một vuông góc (vẽ kiểu "góc phòng", 3 dấu vuông tại O).
+#let hinh-chop-tam-dien-vuong(
+ w: 6.6cm,
+ ten: ($O$, $A$, $B$, $C$),
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -2.9, xmax: 3.3, ymin: -2.7, ymax: 3.4, ctx => {
+ let d = (O: (0.0, 0.0), A: (-2.1, -1.9), B: (2.7, -0.4), C: (0.0, 2.8))
+ doan(ctx, d.O, d.A, mau: mau, day: day, dut: true)
+ doan(ctx, d.O, d.B, mau: mau, day: day, dut: true)
+ doan(ctx, d.O, d.C, mau: mau, day: day, dut: true)
+ doan(ctx, d.A, d.B, mau: mau, day: day)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.C, d.A, mau: mau, day: day)
+ goc-vuong(ctx, d.O, d.A, d.B, r: 0.30, mau: mau-phu)
+ goc-vuong(ctx, d.O, d.B, d.C, r: 0.38, mau: mau-phu)
+ goc-vuong(ctx, d.O, d.A, d.C, r: 0.46, mau: mau-phu)
+ diem(ctx, d.O, ten: ten.at(0), huong: "tren-phai", bk: 1.5pt)
+ diem(ctx, d.A, ten: ten.at(1), huong: "duoi-trai", bk: 1.5pt)
+ diem(ctx, d.B, ten: ten.at(2), huong: "phai", bk: 1.5pt)
+ diem(ctx, d.C, ten: ten.at(3), huong: "tren", bk: 1.5pt)
+ goi-them(them, ctx, d)
+})
+
+// ---------- CHÓP ĐÁY TAM GIÁC VUÔNG, CẠNH BÊN VUÔNG GÓC ĐÁY ----------
+// Đáy ABC vuông tại B; SA vuông góc với đáy (S thẳng trên A).
+#let hinh-chop-day-tam-giac-vuong(
+ w: 6.8cm,
+ ten: ($S$, $A$, $B$, $C$),
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -0.7, xmax: 4.6, ymin: -0.6, ymax: 5.9, ctx => {
+ let d = (A: (0.0, 2), B: (3.4, 0.0), C: (4.6, 2), S: (0.0, 5.5))
+ // đáy
+ doan(ctx, d.A, d.B, mau: mau, day: day)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.A, d.C, mau: mau, day: day, dut: true)
+ // cạnh bên
+ doan(ctx, d.S, d.A, mau: mau, day: day)
+ doan(ctx, d.S, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.C, mau: mau, day: day)
+ // SA vuông đáy + đáy vuông tại B
+ goc-vuong(ctx, d.A, d.S, d.B, r: 0.3, mau: mau-phu)
+ goc-vuong(ctx, d.B, d.A, d.C, r: 0.3, mau: mau-phu)
+ let G = trong-tam(d.A, d.B, d.C, d.S)
+ for (P, t) in ((d.S, d.A, d.B, d.C).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- CHÓP ĐÁY HÌNH CHỮ NHẬT, CẠNH BÊN VUÔNG GÓC ĐÁY ----------
+// Đáy ABCD hình chữ nhật; SA vuông góc đáy (S thẳng trên A).
+#let hinh-chop-day-chu-nhat(
+ w: 7.6cm,
+ ten: ($S$, $A$, $B$, $C$, $D$),
+ duong-cheo: false,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -3, xmax: 6.0, ymin: -2, ymax: 5.5, ctx => {
+ let d = (A: (0.0, 0.0), B: (6, 0.0), C: (3, -2), D: (-3, -2), S: (0.0, 4.8))
+ // đáy: nét liền phía trước, nét khuất phía sau
+ doan(ctx, d.A, d.B, mau: mau, day: day, dut: true)
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.A, d.D, mau: mau, day: day, dut: true)
+ doan(ctx, d.D, d.C, mau: mau, day: day)
+ // cạnh bên
+ doan(ctx, d.S, d.A, mau: mau, day: day, dut: true)
+ doan(ctx, d.S, d.B, mau: mau, day: day)
+ doan(ctx, d.S, d.C, mau: mau, day: day)
+ doan(ctx, d.S, d.D, mau: mau, day: day)
+ if duong-cheo {
+ doan(ctx, d.A, d.C, mau: mau, day: 0.9pt, dut: true)
+ doan(ctx, d.B, d.D, mau: mau, day: 0.9pt, dut: true)
+ }
+ // SA vuông với AB và AD
+ goc-vuong(ctx, d.A, d.S, d.B, r: 0.3, mau: mau-phu)
+ goc-vuong(ctx, d.A, d.S, d.D, r: 0.42, mau: mau-phu)
+ let G = trong-tam(d.A, d.B, d.C, d.D, d.S)
+ for (P, t) in ((d.S, d.A, d.B, d.C, d.D).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH HỘP CHỮ NHẬT / LẬP PHƯƠNG ABCD.A'B'C'D' ----------
+// Đáy ABCD, đỉnh trên A'B'C'D'. dai, cao: kích thước; sau: độ "lùi" phối cảnh.
+#let hinh-hop(
+ w: 7.6cm,
+ dai: 3.6, cao: 2.6, sau: 1.5, nghieng: -0.7,
+ ten: ($A$, $B$, $C$, $D$, $A'$, $B'$, $C'$, $D'$),
+ duong-cheo: false, // đường chéo không gian AC' (đứt)
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = {
+ let dx = -sau * 0.75
+ let dy = -sau * 0.62
+ hinh(
+ w: w,
+ xmin: -dx - 2, xmax: dai - dx ,
+ ymin: -dy - 2, ymax: cao - dy,
+ ctx => {
+ let d = (
+ A: (0.0, 0.0),
+ B: (dai, 0.0),
+ C: (dai + dx, dy),
+ D: (dx, dy),
+ )
+ d.insert("A1", (d.A.at(0) + nghieng, d.A.at(1) + cao))
+ d.insert("B1", (d.B.at(0) + nghieng, d.B.at(1) + cao))
+ d.insert("C1", (d.C.at(0) + nghieng, d.C.at(1) + cao))
+ d.insert("D1", (d.D.at(0) + nghieng, d.D.at(1) + cao))
+ // nét liền: mặt trước, mặt phải, mặt trên
+ duong-cong(ctx, (d.A1, d.B1, d.C1, d.D1), mau: mau, day: day, dong: true)
+
+
+ doan(ctx, d.B, d.C, mau: mau, day: day)
+ doan(ctx, d.D, d.C, mau: mau, day: day)
+ doan(ctx, d.D, d.D1, mau: mau, day: day)
+ doan(ctx, d.B, d.B1, mau: mau, day: day)
+ doan(ctx, d.C, d.C1, mau: mau, day: day)
+ // nét khuất: 3 cạnh tại đỉnh A
+ doan(ctx, d.A, d.D, mau: mau, day: day, dut: true)
+ doan(ctx, d.A, d.B, mau: mau, day: day, dut: true)
+ doan(ctx, d.A, d.A1, mau: mau, day: day, dut: true)
+
+ if duong-cheo {
+ doan(ctx, d.A, d.C1, mau: mau-phu, day: 1pt, dut: true)
+ }
+ let G = trong-tam(d.A, d.C1)
+ for (P, t) in ((d.A, d.B, d.C, d.D, d.A1, d.B1, d.C1, d.D1).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+ },
+ )
+}
+
+// Hình lập phương cạnh chuẩn.
+#let hinh-lap-phuong(w: 7cm, ten: ($A$, $B$, $C$, $D$, $A'$, $B'$, $C'$, $D'$), duong-cheo: false, them: none) = {
+ hinh-hop(w: w, dai: 2.8, cao: 2.8, sau: 1.5, nghieng: 0, ten: ten, duong-cheo: duong-cheo, them: them)
+}
+
+// Hình lập phương cạnh chuẩn.
+#let hinh-hop-chu-nhat(w: 7cm, ten: ($A$, $B$, $C$, $D$, $A'$, $B'$, $C'$, $D'$), duong-cheo: false, them: none) = {
+ hinh-hop(w: w, dai: 4, cao: 2.8, sau: 1.5, nghieng: 0, ten: ten, duong-cheo: duong-cheo, them: them)
+}
+
+
+// ---------- LĂNG TRỤ TAM GIÁC ABC.A'B'C' (đứng) ----------
+#let hinh-lang-tru-tam-giac(
+ w: 6.6cm,
+ cao: 3.0,
+ ten: ($A$, $B$, $C$, $A'$, $B'$, $C'$),
+ mau: black, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -0.6, xmax: 4.4, ymin: -0.5, ymax: cao + 1.9, ctx => {
+ let d = (
+ A: (0.0, 0.0),
+ B: (3.4, -0.0),
+ C: (2.4, 1.3),
+ )
+ d.insert("A1", (d.A.at(0), d.A.at(1) + cao))
+ d.insert("B1", (d.B.at(0), d.B.at(1) + cao))
+ d.insert("C1", (d.C.at(0), d.C.at(1) + cao))
+ // đáy trên: nét liền
+ da-giac(ctx, (d.A1, d.B1, d.C1), mau: mau, day: day)
+ // cạnh bên
+ doan(ctx, d.A, d.A1, mau: mau, day: day)
+ doan(ctx, d.B, d.B1, mau: mau, day: day)
+ doan(ctx, d.C, d.C1, mau: mau, day: day, dut: true)
+ // đáy dưới
+ doan(ctx, d.A, d.B, mau: mau, day: day)
+ doan(ctx, d.A, d.C, mau: mau, day: day, dut: true)
+ doan(ctx, d.B, d.C, mau: mau, day: day, dut: true)
+ let G = trong-tam(d.A, d.B, d.C, d.A1, d.B1, d.C1)
+ for (P, t) in ((d.A, d.B, d.C, d.A1, d.B1, d.C1).zip(ten)) {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH NÓN ----------
+#let hinh-non(
+ w: 5.6cm,
+ r: 1.6, cao: 3.4,
+ ten-dinh: $S$, ten-tam: $O$, ten-bk: $r$,
+ truc: true,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -r - 0.7, xmax: r + 0.7, ymin: -r * 0.42 - 0.5, ymax: cao + 0.6, ctx => {
+ let ry = r * 0.32
+ let S = (0.0, cao)
+ let O = (0.0, 0.0)
+ let d = (S: S, O: O, T1: (-r, 0.0), T2: (r, 0.0))
+ // đáy: nửa trước liền, nửa sau đứt
+ cung-elip(ctx, O, r, ry, tu: 180deg, den: 360deg, mau: mau, day: day)
+ cung-elip(ctx, O, r, ry, tu: 0deg, den: 180deg, mau: mau, day: day, dut: true)
+ // hai đường sinh
+ doan(ctx, S, d.T1, mau: mau, day: day)
+ doan(ctx, S, d.T2, mau: mau, day: day)
+ if truc {
+ doan(ctx, S, O, mau: mau-phu, day: 1pt, dut: true)
+ doan(ctx, O, d.T2, mau: mau-phu, day: 1pt)
+ goc-vuong(ctx, O, S, d.T2, r: 0.24, mau: mau-phu)
+ nhan(ctx, (r / 2, 0), ten-bk, huong: "duoi", mau: mau-phu)
+ diem(ctx, O, ten: ten-tam, huong: "duoi-trai", bk: 1.5pt)
+ }
+ diem(ctx, S, ten: ten-dinh, huong: "tren", bk: 1.5pt)
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH TRỤ ----------
+#let hinh-tru(
+ w: 5.6cm,
+ r: 1.5, cao: 3.2,
+ ten-tam: ($O$, $O'$), ten-bk: $r$,
+ truc: true,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -r - 0.7, xmax: r + 0.7, ymin: -r * 0.42 - 0.4, ymax: cao + r * 0.42 + 0.5, ctx => {
+ let ry = r * 0.3
+ let O = (0.0, 0.0)
+ let O1 = (0.0, cao)
+ let d = (O: O, O1: O1)
+ // đáy dưới: nửa trước liền, nửa sau đứt
+ cung-elip(ctx, O, r, ry, tu: 180deg, den: 360deg, mau: mau, day: day)
+ cung-elip(ctx, O, r, ry, tu: 0deg, den: 180deg, mau: mau, day: day, dut: true)
+ // đáy trên: liền hoàn toàn
+ cung-elip(ctx, O1, r, ry, tu: 0deg, den: 360deg, mau: mau, day: day)
+ // hai cạnh bên
+ doan(ctx, (-r, 0), (-r, cao), mau: mau, day: day)
+ doan(ctx, (r, 0), (r, cao), mau: mau, day: day)
+ if truc {
+ doan(ctx, O, O1, mau: mau-phu, day: 1pt, dut: true)
+ doan(ctx, O1, (r, cao), mau: mau-phu, day: 1pt)
+ nhan(ctx, (r / 2, cao), ten-bk, huong: "tren", mau: mau-phu)
+ diem(ctx, O, ten: ten-tam.at(0), huong: "trai", bk: 1.5pt)
+ diem(ctx, O1, ten: ten-tam.at(1), huong: "trai", bk: 1.5pt)
+ }
+ goi-them(them, ctx, d)
+})
+
+// ---------- HÌNH CẦU ----------
+#let hinh-cau(
+ w: 5.6cm,
+ r: 2.0,
+ ten-tam: $O$, ten-bk: $R$,
+ ban-kinh: true,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = hinh(w: w, xmin: -r - 0.5, xmax: r + 0.5, ymin: -r - 0.5, ymax: r + 0.5, ctx => {
+ let O = (0.0, 0.0)
+ let ry = r * 0.32
+ duong-tron(ctx, O, r, mau: mau, day: day)
+ // xích đạo: nửa trước liền, nửa sau đứt
+ cung-elip(ctx, O, r, ry, tu: 180deg, den: 360deg, mau: mau, day: 0.9pt)
+ cung-elip(ctx, O, r, ry, tu: 0deg, den: 180deg, mau: mau, day: 0.9pt, dut: true)
+ diem(ctx, O, ten: ten-tam, huong: "duoi-trai", bk: 1.5pt)
+ if ban-kinh {
+ let M = (r * calc.cos(40deg), r * calc.sin(40deg))
+ doan(ctx, O, M, mau: mau-phu, day: 1pt)
+ diem(ctx, M, ten: none, bk: 1.5pt, mau: mau-phu)
+ nhan(ctx, trung-diem(O, M), ten-bk, huong: "tren-trai", mau: mau-phu)
+ }
+ goi-them(them, ctx, (O: O))
+})
+
+// ---------- HỆ TRỤC TOẠ ĐỘ Oxyz ----------
+#let truc-oxyz(
+ w: 6cm,
+ ten: ($x$, $y$, $z$),
+ ten-goc: $O$,
+ don-vi: false, // vẽ 3 điểm đơn vị i, j, k
+ mau: black, day: 1pt,
+ them: none,
+) = hinh(w: w, xmin: -2.2, xmax: 2.6, ymin: -1.9, ymax: 2.6, ctx => {
+ let O = (0.0, 0.0)
+ // Ox: chéo xuống trái (hướng về người nhìn), Oy: phải, Oz: lên
+ let X = (-1.55, -1.55)
+ let Y = (2.3, 0.0)
+ let Z = (0.0, 2.3)
+ mui-ten(ctx, O, X, mau: mau, day: day)
+ mui-ten(ctx, O, Y, mau: mau, day: day)
+ mui-ten(ctx, O, Z, mau: mau, day: day)
+ nhan(ctx, X, ten.at(0), huong: "duoi-trai")
+ nhan(ctx, Y, ten.at(1), huong: "duoi")
+ nhan(ctx, Z, ten.at(2), huong: "phai")
+ diem(ctx, O, ten: ten-goc, huong: "tren-phai", bk: 1.5pt)
+ if don-vi {
+ vecto(ctx, O, (-0.62, -0.62), ten: $arrow(i)$, huong: "duoi", mau: red)
+ vecto(ctx, O, (0.9, 0), ten: $arrow(j)$, huong: "tren", mau: red)
+ vecto(ctx, O, (0, 0.9), ten: $arrow(k)$, huong: "trai", mau: red)
+ }
+ goi-them(them, ctx, (O: O))
+})
+
+// =====================================================================
+// HỆ TRỤC Oxyz CÓ TOẠ ĐỘ THẬT — điểm, vectơ, hộp gióng theo đơn vị
+// Phép chiếu: Oy sang phải, Oz lên trên, Ox chéo xuống-trái về người nhìn.
+// #oxyz(x: 5, y: 8, z: 8, them: (ctx, t3) => {
+// diem-oxyz(ctx, t3, (5, 8, 8), ten: $B$, huong: "above-right")
+// giong-oxyz(ctx, t3, (5, 8, 8))
+// vecto-oxyz(ctx, t3, (0, 0, 0), (5, 8, 8), mau: purple)
+// })
+// `them` nhận (ctx, t3): t3 đổi (x, y, z) -> điểm 2D, dùng được với MỌI
+// hàm vẽ phẳng: diem(ctx, t3((1,2,3)), ...), doan(ctx, t3(A), t3(B), ...).
+// =====================================================================
+// Điểm 3D: chấm + nhãn.
+#let diem-oxyz(ctx, t3, P, ten: none, huong: "above", bk: 2pt, mau: black) = {
+ diem(ctx, t3(P), ten: ten, huong: huong, bk: bk, mau: mau)
+}
+
+// Đoạn / vectơ nối hai điểm 3D.
+#let doan-oxyz(ctx, t3, A, B, mau: black, day: 1pt, dut: false) = {
+ doan(ctx, t3(A), t3(B), mau: mau, day: day, dut: dut)
+}
+#let vecto-oxyz(ctx, t3, A, B, ten: none, huong: "tren", mau: black, day: 1.1pt, dut: false) = {
+ vecto(ctx, t3(A), t3(B), ten: ten, huong: huong, mau: mau, day: day, dut: dut)
+}
+
+// Hộp gióng nét đứt từ gốc O đến điểm P = (a, b, c): vẽ các cạnh của
+// hình hộp chữ nhật [0,a]×[0,b]×[0,c] (trừ 3 cạnh nằm trên trục).
+#let giong-oxyz(ctx, t3, P, mau: blue, day: 0.8pt) = {
+ let (a, b, c) = (P.at(0), P.at(1), P.at(2))
+ let canh = ()
+ if a != 0 and b != 0 {
+ canh += (((a, 0, 0), (a, b, 0)), ((0, b, 0), (a, b, 0)))
+ }
+ if c != 0 {
+ if a != 0 or b != 0 { canh += (((a, b, 0), (a, b, c)),) }
+ if a != 0 { canh += (((0, 0, c), (a, 0, c)), ((a, 0, 0), (a, 0, c))) }
+ if b != 0 { canh += (((0, 0, c), (0, b, c)), ((0, b, 0), (0, b, c))) }
+ if a != 0 and b != 0 {
+ canh += (((a, 0, c), (a, b, c)), ((0, b, c), (a, b, c)))
+ }
+ }
+ for (A, B) in canh {
+ doan(ctx, t3(A), t3(B), mau: mau, day: day, dut: true)
+ }
+}
+
+// Các mốc bội của buoc trong [a, b], bỏ 0 (vạch chia/số/lưới của oxyz).
+#let _moc-oxyz(a, b, buoc) = {
+ let kq = ()
+ let t = calc.ceil(a / buoc - 0.0001) * buoc
+ while t <= b + 0.0001 {
+ if calc.abs(t) > 0.0001 { kq.push(t) }
+ t = t + buoc
+ }
+ kq
+}
+
+// Phạm vi một trục: n -> (0, n) | số âm -> (n, 0) | tuple (mn, mx) giữ nguyên.
+#let _mien-oxyz(v) = if type(v) == array {
+ (v.at(0), v.at(1))
+} else if v < 0 { (v, 0) } else { (0, v) }
+
+// Định dạng số gọn cho nhãn trục: 2.0 -> "2", 0.5 -> "0.5".
+#let _so-oxyz(t) = if calc.abs(t - calc.round(t)) < 0.0001 {
+ str(int(calc.round(t)))
+} else { str(t) }
+
+#let oxyz(
+ w: 7cm,
+ x: 4, y: 5, z: 4, // phạm vi trục: n = [0, n] | số âm = [n, 0]
+ // | tuple (mn, mx) = hai phía, vd x: (-2, 3)
+ am: 0.8, // kéo dài thêm trục quá phạm vi (phía không mũi tên)
+ ten: ($x$, $y$, $z$), ten-goc: $O$,
+ don-vi: true, // vẽ 3 vectơ đơn vị i, j, k
+ mau-don-vi: (red, purple, green.darken(30%)),
+ vach: false, // vạch chia đơn vị trên 3 trục
+ so: false, // ghi số tại các vạch (bỏ qua 0)
+ buoc: 1, // bước của vạch chia / số / lưới
+ luoi: (), // lưới trên mặt phẳng toạ độ: true = cả 3,
+ // hoặc tuple chọn: ("xy",), ("xy", "xz", "yz")
+ mau-luoi: luma(84%),
+ k: 0.55, goc: 35deg, // hệ số & góc chiếu của trục Ox
+ mau: black, day: 0.9pt,
+ co-chu: 10pt,
+ them: none, // (ctx, t3) => nội dung vẽ thêm
+) = {
+ let t3 = P => (
+ P.at(1) - k * P.at(0) * calc.cos(goc),
+ P.at(2) - k * P.at(0) * calc.sin(goc),
+ )
+ let dm = 0.9 // lề cho mũi tên + nhãn
+ // phạm vi từng trục (chấp nhận số âm / tuple 2 phía)
+ let (xa, xb) = _mien-oxyz(x)
+ let (ya, yb) = _mien-oxyz(y)
+ let (za, zb) = _mien-oxyz(z)
+ // cửa sổ: bao 8 đỉnh hộp phạm vi và đầu mút các trục
+ let chot = (
+ (xb + am + dm, 0, 0), (xa - am, 0, 0),
+ (0, yb + am + dm, 0), (0, ya - am, 0),
+ (0, 0, zb + am + dm), (0, 0, za - am),
+ )
+ for cx in (xa, xb) {
+ for cy in (ya, yb) {
+ for cz in (za, zb) { chot.push((cx, cy, cz)) }
+ }
+ }
+ let chot = chot.map(t3)
+ let xs = chot.map(p => p.at(0))
+ let ys = chot.map(p => p.at(1))
+ let (x1, x2) = (calc.min(..xs) - 0.35, calc.max(..xs) + 0.35)
+ let (y1, y2) = (calc.min(..ys) - 0.35, calc.max(..ys) + 0.35)
+ hinh(w: w, xmin: x1, xmax: x2, ymin: y1, ymax: y2, co-chu: co-chu, ctx => {
+ // lưới trên các mặt phẳng toạ độ (vẽ TRƯỚC để nằm dưới trục)
+ let mp = if luoi == true { ("xy", "xz", "yz") } else if luoi == none or luoi == false { () } else if type(luoi) == str { (luoi,) } else { luoi }
+ for m in mp {
+ if m == "xy" {
+ for i in _moc-oxyz(xa, xb, buoc) { doan(ctx, t3((i, ya, 0)), t3((i, yb, 0)), mau: mau-luoi, day: 0.5pt) }
+ for j in _moc-oxyz(ya, yb, buoc) { doan(ctx, t3((xa, j, 0)), t3((xb, j, 0)), mau: mau-luoi, day: 0.5pt) }
+ } else if m == "xz" {
+ for i in _moc-oxyz(xa, xb, buoc) { doan(ctx, t3((i, 0, za)), t3((i, 0, zb)), mau: mau-luoi, day: 0.5pt) }
+ for l in _moc-oxyz(za, zb, buoc) { doan(ctx, t3((xa, 0, l)), t3((xb, 0, l)), mau: mau-luoi, day: 0.5pt) }
+ } else if m == "yz" {
+ for j in _moc-oxyz(ya, yb, buoc) { doan(ctx, t3((0, j, za)), t3((0, j, zb)), mau: mau-luoi, day: 0.5pt) }
+ for l in _moc-oxyz(za, zb, buoc) { doan(ctx, t3((0, ya, l)), t3((0, yb, l)), mau: mau-luoi, day: 0.5pt) }
+ }
+ }
+ mui-ten(ctx, t3((xa - am, 0, 0)), t3((xb + am + dm, 0, 0)), mau: mau, day: day)
+ mui-ten(ctx, t3((0, ya - am, 0)), t3((0, yb + am + dm, 0)), mau: mau, day: day)
+ mui-ten(ctx, t3((0, 0, za - am)), t3((0, 0, zb + am + dm)), mau: mau, day: day)
+ nhan(ctx, t3((xb + am + dm, 0, 0)), ten.at(0), huong: "below-left", cach: 5pt)
+ nhan(ctx, t3((0, yb + am + dm, 0)), ten.at(1), huong: "below", cach: 5pt)
+ nhan(ctx, t3((0, 0, zb + am + dm)), ten.at(2), huong: "left", cach: 5pt)
+ nhan(ctx, t3((0, 0, 0)), ten-goc, huong: "below-right", cach: 4pt)
+ // vạch chia đơn vị + số trên 3 trục (vạch vuông góc với trục trên trang)
+ if vach or so {
+ let bo3 = (
+ ((1, 0, 0), xa, xb, tm => t3((tm, 0, 0)), "below"),
+ ((0, 1, 0), ya, yb, tm => t3((0, tm, 0)), "below"),
+ ((0, 0, 1), za, zb, tm => t3((0, 0, tm)), "left"),
+ )
+ for (u, lo, hi, f, hg) in bo3 {
+ let o2 = toa-pt(ctx, t3((0, 0, 0)))
+ let u2 = toa-pt(ctx, t3(u))
+ let (dx, dy) = (u2.at(0) - o2.at(0), u2.at(1) - o2.at(1))
+ let l = calc.sqrt(dx * dx + dy * dy)
+ let (nx, ny) = (-dy / l, dx / l)
+ for tm in _moc-oxyz(lo, hi, buoc) {
+ let p = toa-pt(ctx, f(tm))
+ if vach {
+ doan-pt(
+ (p.at(0) - 2.2 * nx, p.at(1) - 2.2 * ny),
+ (p.at(0) + 2.2 * nx, p.at(1) + 2.2 * ny),
+ mau: mau, day: 0.8pt,
+ )
+ }
+ if so { nhan(ctx, f(tm), $#_so-oxyz(tm)$, huong: hg, cach: 4pt) }
+ }
+ }
+ }
+ if don-vi {
+ vecto(ctx, t3((0, 0, 0)), t3((1, 0, 0)), ten: $arrow(i)$, huong: "trai", mau: mau-don-vi.at(0), day: 1.2pt)
+ vecto(ctx, t3((0, 0, 0)), t3((0, 1, 0)), ten: $arrow(j)$, huong: "tren", mau: mau-don-vi.at(1), day: 1.2pt)
+ vecto(ctx, t3((0, 0, 0)), t3((0, 0, 1)), ten: $arrow(k)$, huong: "trai", mau: mau-don-vi.at(2), day: 1.2pt)
+ }
+ if them != none { them(ctx, t3) }
+ })
+}
+
+// =====================================================================
+// LỤC GIÁC ĐỀU & CHÓP CỤT ĐỀU — engine chiếu + phân loại nét khuất
+// Đáy đa giác đều NẰM NGANG (z = const), nhìn chếch từ trước-trên.
+// Nét khuất (đáy sau + cạnh bên tới đỉnh sau) TỰ vẽ đứt theo hình chiếu.
+// #hinh-chop-luc-giac-deu() — chóp lục giác đều S.ABCDEF
+// #hinh-lang-tru-luc-giac-deu() — lăng trụ lục giác đều ABCDEF.A'…F'
+// #hinh-chop-cut-deu(n: 3|4|6) — chóp cụt đều tam/tứ/lục giác
+// =====================================================================
+// Chiếu điểm 3D (x, y, z) -> điểm 2D trên trang (y = chiều sâu ra sau).
+#let _pr(P) = (
+ P.at(0) + 0.5 * P.at(1) * calc.cos(40deg),
+ P.at(2) + 0.5 * P.at(1) * calc.sin(40deg),
+)
+
+// Đỉnh đa giác đều n cạnh, tâm trục Oz, bán kính R, độ cao z, lệch pha.
+#let _dinh-deu(n, R, z, pha) = range(n).map(i => {
+ let a = pha + i * 360deg / n
+ (R * calc.cos(a), R * calc.sin(a), z)
+})
+
+// Pha mặc định: đỉnh A rơi vào phía trước-trái, các cạnh trước hiện rõ.
+// n = 6 dùng 210° (như n = 3): các đỉnh toả đều 2 phía, D và E (hay D', E')
+// không dồn sát nhau bên phải như pha 180° cũ.
+#let _pha-deu(n) = if n == 3 { 210deg } else if n == 4 { 225deg } else { 210deg }
+
+// Phân loại nét khuất của đáy: trả (canh-sau, dinh-sau).
+// P2: đỉnh đáy đã chiếu; y3: độ sâu 3D tương ứng. Đáy lồi -> hai chuỗi
+// giữa đỉnh trái-nhất và phải-nhất; chuỗi có độ sâu lớn hơn là "sau" (khuất).
+#let _khuat(P2, y3) = {
+ let n = P2.len()
+ let L = 0
+ let R = 0
+ for i in range(n) {
+ if P2.at(i).at(0) < P2.at(L).at(0) { L = i }
+ if P2.at(i).at(0) > P2.at(R).at(0) { R = i }
+ }
+ let ch1 = ()
+ let i = L
+ while i != R { ch1.push(i); i = calc.rem(i + 1, n) }
+ ch1.push(R)
+ let ch2 = ()
+ let j = L
+ while j != R { ch2.push(j); j = calc.rem(j + n - 1, n) }
+ ch2.push(R)
+ let tb(ch) = ch.map(k => y3.at(k)).sum() / ch.len()
+ let sau = if tb(ch1) >= tb(ch2) { ch1 } else { ch2 }
+ let canh-sau = ()
+ for t in range(sau.len() - 1) { canh-sau.push((sau.at(t), sau.at(t + 1))) }
+ (canh-sau: canh-sau, dinh-sau: sau.slice(1, sau.len() - 1))
+}
+#let _la-khuat(canh, i, j) = canh.any(c => (c.at(0) == i and c.at(1) == j) or (c.at(0) == j and c.at(1) == i))
+
+// Cửa sổ bao các điểm 2D + lề.
+#let _cua-so-2d(pts, mg: 0.7) = {
+ let xs = pts.map(p => p.at(0))
+ let ys = pts.map(p => p.at(1))
+ (xmin: calc.min(..xs) - mg, xmax: calc.max(..xs) + mg, ymin: calc.min(..ys) - mg, ymax: calc.max(..ys) + mg)
+}
+
+// ---------- CHÓP LỤC GIÁC ĐỀU S.ABCDEF ----------
+#let hinh-chop-luc-giac-deu(
+ w: 7cm,
+ R: 2.0, cao: 3.8,
+ ten: ($S$, $A$, $B$, $C$, $D$, $E$, $F$),
+ ten-tam: $O$,
+ duong-cao: true,
+ mau: black, mau-phu: red, day: 1.1pt,
+ them: none,
+) = {
+ let n = 6
+ let B3 = _dinh-deu(n, R, 0, _pha-deu(n))
+ let S3 = (0, 0, cao)
+ let O3 = (0, 0, 0)
+ let B2 = B3.map(_pr)
+ let S2 = _pr(S3)
+ let O2 = _pr(O3)
+ let kl = _khuat(B2, B3.map(P => P.at(1)))
+ let cs = _cua-so-2d(B2 + (S2,))
+ hinh(w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax, ctx => {
+ // cạnh đáy: sau đứt
+ for i in range(n) {
+ let j = calc.rem(i + 1, n)
+ doan(ctx, B2.at(i), B2.at(j), mau: mau, day: day, dut: _la-khuat(kl.canh-sau, i, j))
+ }
+ // cạnh bên: tới đỉnh sau thì đứt
+ for i in range(n) {
+ doan(ctx, S2, B2.at(i), mau: mau, day: day, dut: kl.dinh-sau.contains(i))
+ }
+ if duong-cao {
+ doan(ctx, S2, O2, mau: mau-phu, day: 1pt, dut: true)
+ goc-vuong(ctx, O2, S2, B2.at(0), r: 0.26, mau: mau-phu)
+ diem(ctx, O2, ten: ten-tam, huong: "below", bk: 1.5pt, mau: mau-phu)
+ }
+ let G = trong-tam(..B2, S2)
+ diem(ctx, S2, ten: ten.at(0), huong: huong-ra(S2, G))
+ for i in range(n) { diem(ctx, B2.at(i), ten: ten.at(i + 1), huong: huong-ra(B2.at(i), G)) }
+ if them != none { them(ctx, (S: S3, O: O3, day: B3)) }
+ })
+}
+
+// ---------- LĂNG TRỤ LỤC GIÁC ĐỀU ABCDEF.A'B'C'D'E'F' ----------
+#let hinh-lang-tru-luc-giac-deu(
+ w: 7cm,
+ R: 1.9, cao: 3.4,
+ ten: ($A$, $B$, $C$, $D$, $E$, $F$, $A'$, $B'$, $C'$, $D'$, $E'$, $F'$),
+ mau: black, day: 1.1pt,
+ them: none,
+) = {
+ let n = 6
+ let pha = _pha-deu(n)
+ let B3 = _dinh-deu(n, R, 0, pha)
+ let T3 = _dinh-deu(n, R, cao, pha)
+ let B2 = B3.map(_pr)
+ let T2 = T3.map(_pr)
+ let kl = _khuat(B2, B3.map(P => P.at(1)))
+ let cs = _cua-so-2d(B2 + T2)
+ hinh(w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax, ctx => {
+ // đáy trên: liền hết
+ for i in range(n) {
+ let j = calc.rem(i + 1, n)
+ doan(ctx, T2.at(i), T2.at(j), mau: mau, day: day)
+ }
+ // cạnh bên
+ for i in range(n) {
+ doan(ctx, B2.at(i), T2.at(i), mau: mau, day: day, dut: kl.dinh-sau.contains(i))
+ }
+ // đáy dưới: sau đứt
+ for i in range(n) {
+ let j = calc.rem(i + 1, n)
+ doan(ctx, B2.at(i), B2.at(j), mau: mau, day: day, dut: _la-khuat(kl.canh-sau, i, j))
+ }
+ let Gb = trong-tam(..B2)
+ let Gt = trong-tam(..T2)
+ for i in range(n) { diem(ctx, B2.at(i), ten: ten.at(i), huong: huong-ra(B2.at(i), Gt)) }
+ for i in range(n) { diem(ctx, T2.at(i), ten: ten.at(i + n), huong: huong-ra(T2.at(i), Gb)) }
+ if them != none { them(ctx, (duoi: B3, tren: T3)) }
+ })
+}
+
+// ---------- CHÓP CỤT ĐỀU (tam/tứ/lục giác) ABCD….A'B'C'D'… ----------
+// n: 3 | 4 | 6. R: bán kính đáy lớn (dưới), r: bán kính đáy nhỏ (trên).
+#let hinh-chop-cut-luc-giac-deu(
+ n: 6,
+ w: 7cm,
+ R: 2.3, r: 1.25, cao: 3.0,
+ ten: auto, // auto: A,B,… (đáy dưới) + phẩy (đáy trên); hoặc mảng 2n nội dung
+ mau: black, day: 1.1pt,
+ truc: false, ten-tam: ($O$, $O'$),
+ mau-phu: red,
+ them: none,
+) = {
+ let pha = _pha-deu(n)
+ let B3 = _dinh-deu(n, R, 0, pha)
+ let T3 = _dinh-deu(n, r, cao, pha)
+ let B2 = B3.map(_pr)
+ let T2 = T3.map(_pr)
+ let kl = _khuat(B2, B3.map(P => P.at(1)))
+ let chu = ("A", "B", "C", "D", "E", "F", "G", "H")
+ let ten2 = if ten == auto {
+ range(n).map(i => eval(chu.at(i), mode: "math")) + range(n).map(i => eval(chu.at(i) + "'", mode: "math"))
+ } else { ten }
+ let O3 = (0, 0, 0)
+ let O13 = (0, 0, cao)
+ let cs = _cua-so-2d(B2 + T2)
+ hinh(w: w, xmin: cs.xmin, xmax: cs.xmax, ymin: cs.ymin, ymax: cs.ymax, ctx => {
+ // đáy trên: liền hết
+ for i in range(n) {
+ let j = calc.rem(i + 1, n)
+ doan(ctx, T2.at(i), T2.at(j), mau: mau, day: day)
+ }
+ // cạnh bên
+ for i in range(n) {
+ doan(ctx, B2.at(i), T2.at(i), mau: mau, day: day, dut: kl.dinh-sau.contains(i))
+ }
+ // đáy dưới: sau đứt
+ for i in range(n) {
+ let j = calc.rem(i + 1, n)
+ doan(ctx, B2.at(i), B2.at(j), mau: mau, day: day, dut: _la-khuat(kl.canh-sau, i, j))
+ }
+ if truc {
+ let O2 = _pr(O3)
+ let O12 = _pr(O13)
+ doan(ctx, O2, O12, mau: mau-phu, day: 1pt, dut: true)
+ diem(ctx, O2, ten: ten-tam.at(0), huong: "below", bk: 1.1pt, mau: mau-phu)
+ diem(ctx, O12, ten: ten-tam.at(1), huong: "above", bk: 1.1pt, mau: mau-phu)
+ }
+ let Gb = trong-tam(..B2)
+ let Gt = trong-tam(..T2)
+ for i in range(n) { diem(ctx, B2.at(i), ten: ten2.at(i), huong: huong-ra(B2.at(i), Gt)) }
+ for i in range(n) { diem(ctx, T2.at(i), ten: ten2.at(i + n), huong: huong-ra(T2.at(i), Gb)) }
+ if them != none { them(ctx, (duoi: B3, tren: T3)) }
+ })
+}
+
+// Bí danh chóp cụt đều theo số cạnh đáy (cùng bộ tham số ở trên):
+// #hinh-chop-cut-deu(n: 3|4|6) — gọi chung
+// #hinh-chop-cut-tam-giac-deu() — ABC.A'B'C'
+// #hinh-chop-cut-tu-giac-deu() — ABCD.A'B'C'D'
+#let hinh-chop-cut-deu = hinh-chop-cut-luc-giac-deu
+#let hinh-chop-cut-tam-giac-deu(..th) = hinh-chop-cut-luc-giac-deu(n: 3, ..th)
+#let hinh-chop-cut-tu-giac-deu(..th) = hinh-chop-cut-luc-giac-deu(n: 4, ..th)
diff --git a/packages/preview/conic-toan/0.3.4/lib/hinh-phang.typ b/packages/preview/conic-toan/0.3.4/lib/hinh-phang.typ
new file mode 100644
index 0000000000..b4b2408e0d
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/hinh-phang.typ
@@ -0,0 +1,498 @@
+// =====================================================================
+// hinh-phang.typ — HÌNH HỌC PHẲNG THPT
+// Tam giác + các đường đặc biệt, tứ giác, đường tròn nội/ngoại tiếp,
+// tiếp tuyến, giao hai đường tròn, đường tròn lượng giác...
+// Mọi hàm nhận ctx từ #hinh (xem ve.typ). Toạ độ nên dùng 2 trục cùng tỉ lệ
+// (để h: auto trong #hinh).
+// =====================================================================
+#import "ve.typ": *
+
+// Hướng nhãn tự động: đẩy nhãn ra xa điểm G (thường là trọng tâm hình).
+#let huong-ra(P, G) = {
+ let d = calc.atan2(P.at(0) - G.at(0), P.at(1) - G.at(1)).deg()
+ if d < 0 { d = d + 360 }
+ let idx = calc.rem(calc.floor((d + 22.5) / 45), 8)
+ ("phai", "tren-phai", "tren", "tren-trai", "trai", "duoi-trai", "duoi", "duoi-phai").at(idx)
+}
+
+// Trọng tâm của dãy điểm.
+#let trong-tam(..pts) = {
+ let ds = pts.pos()
+ let n = ds.len()
+ (
+ ds.map(P => P.at(0)).sum() / n,
+ ds.map(P => P.at(1)).sum() / n,
+ )
+}
+
+// Vẽ đa giác + nhãn đỉnh tự động hướng ra ngoài.
+// ten: mảng nội dung nhãn (cùng độ dài số đỉnh) hoặc none.
+#let da-giac-ten(ctx, dinh, ten: none, mau: black, day: 1.1pt, to: none, cham: true) = {
+ da-giac(ctx, dinh, mau: mau, day: day, to: to)
+ let G = trong-tam(..dinh)
+ if ten != none {
+ for (P, t) in dinh.zip(ten) {
+ if cham {
+ diem(ctx, P, ten: t, huong: huong-ra(P, G))
+ } else {
+ nhan(ctx, P, t, huong: huong-ra(P, G))
+ }
+ }
+ }
+}
+
+// ---------- Tam giác ----------
+#let tam-giac(ctx, A, B, C, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, cham: true) = {
+ da-giac-ten(ctx, (A, B, C), ten: ten, mau: mau, day: day, to: to, cham: cham)
+}
+
+// Đường cao hạ từ P xuống đường thẳng AB (kèm ký hiệu vuông góc).
+// ten-chan: nhãn chân đường cao (vd $H$).
+#let duong-cao(ctx, P, A, B, ten-chan: none, mau: red, day: 1pt, dut: false, vuong: true) = {
+ let H = hinh-chieu(P, A, B)
+ doan(ctx, P, H, mau: mau, day: day, dut: dut)
+ if vuong {
+ let Q = if khoang-cach(H, A) > khoang-cach(H, B) { A } else { B }
+ goc-vuong(ctx, H, P, Q, mau: mau)
+ }
+ if ten-chan != none {
+ diem(ctx, H, ten: ten-chan, huong: huong-ra(H, trong-tam(P, A, B)))
+ }
+}
+
+// Trung tuyến từ P đến trung điểm AB (kèm 2 vạch đánh dấu bằng nhau).
+#let trung-tuyen(ctx, P, A, B, ten-chan: none, mau: blue, day: 1pt, dut: false, so-vach: 1) = {
+ let M = trung-diem(A, B)
+ doan(ctx, P, M, mau: mau, day: day, dut: dut)
+ danh-dau(ctx, A, M, so: so-vach, mau: mau)
+ danh-dau(ctx, M, B, so: so-vach, mau: mau)
+ if ten-chan != none {
+ diem(ctx, M, ten: ten-chan, huong: huong-ra(M, trong-tam(P, A, B)))
+ }
+}
+
+// Phân giác trong góc O của tam giác OAB (kèm 2 cung đánh dấu góc bằng nhau).
+// vach : số VẠCH gạch ngang cung (0/1/2/3) — ký hiệu hai góc bằng nhau
+// so-cung : số CUNG đồng tâm vẽ ở mỗi góc (1/2/3)
+// lech : hệ số bán kính cung thứ hai so với cung thứ nhất
+// (auto = 1.18 khi KHÔNG đánh dấu, = 1 khi có vạch/nhiều cung —
+// vì hai góc bằng nhau thì phải vẽ cùng bán kính mới đúng quy ước)
+// ten-goc : nhãn ghi ở CẢ HAI góc (vd $alpha$) — none = không ghi
+// Ví dụ: phan-giac(A, B, C, vach: 1) // mỗi góc một vạch
+// phan-giac(A, B, C, so-cung: 2) // mỗi góc hai cung đồng tâm
+#let phan-giac(
+ ctx, O, A, B,
+ ten-chan: none, mau: green.darken(20%), day: 1pt, dut: false,
+ r-cung: 0.5, vach: 0, so-cung: 1, dai-vach: 6pt, lech: auto,
+ ten-goc: none, cach-nhan: 1.9,
+) = {
+ let oa = khoang-cach(O, A)
+ let ob = khoang-cach(O, B)
+ let D = chia(A, B, oa / (oa + ob))
+ doan(ctx, O, D, mau: mau, day: day, dut: dut)
+ let k = if lech != auto { lech } else if vach > 0 or so-cung > 1 { 1 } else { 1.18 }
+ goc(ctx, O, A, D, r: r-cung, mau: mau, day: day,
+ vach: vach, so-cung: so-cung, dai-vach: dai-vach,
+ ten: ten-goc, cach-nhan: cach-nhan)
+ goc(ctx, O, D, B, r: r-cung * k, mau: mau, day: day,
+ vach: vach, so-cung: so-cung, dai-vach: dai-vach,
+ ten: ten-goc, cach-nhan: cach-nhan)
+ if ten-chan != none {
+ diem(ctx, D, ten: ten-chan, huong: huong-ra(D, trong-tam(O, A, B)))
+ }
+}
+
+// Đường trung trực của đoạn AB (đoạn vuông góc tại trung điểm, dài 2*dai).
+#let trung-truc(ctx, A, B, dai: 1.5, mau: purple, day: 1pt, dut: true, so-vach: 1) = {
+ let M = trung-diem(A, B)
+ let d = khoang-cach(A, B)
+ let ux = (B.at(0) - A.at(0)) / d
+ let uy = (B.at(1) - A.at(1)) / d
+ let P1 = (M.at(0) - dai * uy, M.at(1) + dai * ux)
+ let P2 = (M.at(0) + dai * uy, M.at(1) - dai * ux)
+ doan(ctx, P1, P2, mau: mau, day: day, dut: dut)
+ goc-vuong(ctx, M, B, P1, mau: mau)
+ danh-dau(ctx, A, M, so: so-vach, mau: mau)
+ danh-dau(ctx, M, B, so: so-vach, mau: mau)
+}
+
+// ---------- Tam giác đặc biệt ----------
+// Tam giác đều cạnh `canh`, đỉnh A ở dưới-trái, đáy nằm ngang.
+// vach: số vạch đánh dấu cạnh bằng nhau (0 = không vẽ).
+#let tam-giac-deu(ctx, A, canh, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, vach: 1) = {
+ let B = (A.at(0) + canh, A.at(1))
+ let C = (A.at(0) + canh / 2, A.at(1) + canh * calc.sqrt(3) / 2)
+ da-giac-ten(ctx, (A, B, C), ten: ten, mau: mau, day: day, to: to)
+ if vach > 0 {
+ danh-dau(ctx, A, B, so: vach, mau: mau)
+ danh-dau(ctx, B, C, so: vach, mau: mau)
+ danh-dau(ctx, C, A, so: vach, mau: mau)
+ }
+}
+
+// Tam giác vuông tại A: cạnh AB ngang dài a, cạnh AC đứng dài b.
+#let tam-giac-vuong(ctx, A, a, b, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none) = {
+ let B = (A.at(0) + a, A.at(1))
+ let C = (A.at(0), A.at(1) + b)
+ da-giac-ten(ctx, (A, B, C), ten: ten, mau: mau, day: day, to: to)
+ goc-vuong(ctx, A, B, C, mau: mau)
+}
+
+// Tam giác cân tại C: đáy AB ngang dài `canh-day`, chiều cao `chieu-cao`.
+#let tam-giac-can(ctx, A, canh-day, chieu-cao, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, vach: 1) = {
+ let B = (A.at(0) + canh-day, A.at(1))
+ let C = (A.at(0) + canh-day / 2, A.at(1) + chieu-cao)
+ da-giac-ten(ctx, (A, B, C), ten: ten, mau: mau, day: day, to: to)
+ if vach > 0 {
+ danh-dau(ctx, C, A, so: vach, mau: mau)
+ danh-dau(ctx, C, B, so: vach, mau: mau)
+ }
+}
+
+// Tam giác vuông cân tại A, hai cạnh góc vuông dài `canh`.
+#let tam-giac-vuong-can(ctx, A, canh, ten: ($A$, $B$, $C$), mau: black, day: 1.1pt, to: none, vach: 1) = {
+ tam-giac-vuong(ctx, A, canh, canh, ten: ten, mau: mau, day: day, to: to)
+ if vach > 0 {
+ let B = (A.at(0) + canh, A.at(1))
+ let C = (A.at(0), A.at(1) + canh)
+ danh-dau(ctx, A, B, so: vach, mau: mau)
+ danh-dau(ctx, A, C, so: vach, mau: mau)
+ }
+}
+
+// ---------- Tứ giác thường gặp ----------
+// Tứ giác bất kỳ ABCD.
+#let tu-giac(ctx, A, B, C, D, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none) = {
+ da-giac-ten(ctx, (A, B, C, D), ten: ten, mau: mau, day: day, to: to)
+}
+
+// Hình bình hành biết 3 đỉnh liên tiếp A, B, C (D tự tính = A + C - B).
+#let hinh-binh-hanh(ctx, A, B, C, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none) = {
+ let D = (A.at(0) + C.at(0) - B.at(0), A.at(1) + C.at(1) - B.at(1))
+ da-giac-ten(ctx, (A, B, C, D), ten: ten, mau: mau, day: day, to: to)
+}
+
+// Hình chữ nhật: góc dưới-trái A, chiều rộng r, chiều cao c.
+#let hinh-chu-nhat(ctx, A, r, c, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none, goc-vg: true) = {
+ let B = (A.at(0) + r, A.at(1))
+ let C = (A.at(0) + r, A.at(1) + c)
+ let D = (A.at(0), A.at(1) + c)
+ da-giac-ten(ctx, (A, B, C, D), ten: ten, mau: mau, day: day, to: to)
+ if goc-vg { goc-vuong(ctx, A, B, D, mau: mau) }
+}
+
+// Hình thang: đáy lớn AB (dưới, dài a), đáy nhỏ DC (trên, dài b),
+// cao c, lech: độ lệch ngang của D so với A.
+#let hinh-thang(ctx, A, a, b, c, lech: 0.6, ten: ($A$, $B$, $C$, $D$), mau: black, day: 1.1pt, to: none) = {
+ let B = (A.at(0) + a, A.at(1))
+ let D = (A.at(0) + lech, A.at(1) + c)
+ let C = (D.at(0) + b, D.at(1))
+ da-giac-ten(ctx, (A, B, C, D), ten: ten, mau: mau, day: day, to: to)
+}
+
+// ---------- Đường tròn liên quan tam giác / đa giác ----------
+// Khoảng cách từ P tới ĐOẠN thẳng AB (không phải tới đường thẳng AB).
+#let _cach-doan(P, A, B) = {
+ let ux = B.at(0) - A.at(0)
+ let uy = B.at(1) - A.at(1)
+ let d2 = ux * ux + uy * uy
+ if d2 < 1e-12 { return khoang-cach(P, A) }
+ let t = calc.max(0, calc.min(1, ((P.at(0) - A.at(0)) * ux + (P.at(1) - A.at(1)) * uy) / d2))
+ khoang-cach(P, (A.at(0) + t * ux, A.at(1) + t * uy))
+}
+
+// Khoảng cách từ P tới cạnh GẦN NHẤT của đa giác (mảng đỉnh, khép kín).
+#let _cach-canh(P, dinh) = calc.min(
+ ..range(dinh.len()).map(i => _cach-doan(P, dinh.at(i), dinh.at(calc.rem(i + 1, dinh.len())))),
+)
+
+// 8 hướng đặt nhãn + vectơ tương ứng trong TOẠ ĐỘ TOÁN (y hướng lên).
+#let _tam-huong = (
+ ("phai", (1, 0)), ("tren-phai", (0.7, 0.7)), ("tren", (0, 1)), ("tren-trai", (-0.7, 0.7)),
+ ("trai", (-1, 0)), ("duoi-trai", (-0.7, -0.7)), ("duoi", (0, -1)), ("duoi-phai", (0.7, -0.7)),
+)
+
+// Hướng đặt nhãn tâm sao cho chữ KHÔNG rơi lên cạnh nào của đa giác.
+#let _huong-thoang(O, dinh, r) = {
+ let d = _tam-huong.map(((ten, v)) => (
+ ten,
+ _cach-canh((O.at(0) + 0.3 * r * v.at(0), O.at(1) + 0.3 * r * v.at(1)), dinh),
+ ))
+ let tot = calc.max(..d.map(x => x.at(1)))
+ d.find(x => x.at(1) == tot).at(0)
+}
+
+// Đường tròn ngoại tiếp TAM GIÁC hoặc ĐA GIÁC.
+// duong-tron-ngoai-tiep(A, B, C) — 3 đỉnh rời (lối cũ)
+// duong-tron-ngoai-tiep((A, B, C, D, E)) — MỘT MẢNG đỉnh, đa giác tuỳ ý
+// Từ 4 đỉnh trở lên, tâm/bán kính khớp theo bình phương bé nhất (đa giác nội
+// tiếp được cho ra đúng đường tròn của nó — xem `tron-qua-diem`).
+// ten-tam : nhãn tâm (none = không ghi)
+// huong-tam : auto = tự chọn phía THOÁNG nhất, không đè lên cạnh đa giác
+// ban-kinh : vẽ bán kính (nét đứt) tới một đỉnh; đỉnh được chọn TỰ ĐỘNG
+// sao cho bán kính không nằm đè lên cạnh (vd tam giác vuông:
+// tâm ở giữa cạnh huyền, bán kính tới hai đầu huyền là trùng
+// cạnh — hàm tự tránh, chọn đỉnh góc vuông)
+// dinh-r : chỉ số đỉnh muốn vẽ bán kính tới (auto = tự chọn như trên)
+// ten-r : nhãn ghi trên bán kính (vd $R$)
+// canh : true = vẽ luôn đa giác (mặc định false, thường vẽ sẵn rồi)
+#let duong-tron-ngoai-tiep(
+ ctx, ..vi-tri,
+ ten-tam: $O$, huong-tam: auto,
+ mau: blue, day: 1pt, dut: false, to: none,
+ ban-kinh: false, dinh-r: auto, ten-r: none,
+ canh: false, mau-canh: black,
+) = {
+ let ds = vi-tri.pos()
+ let dinh = if ds.len() == 1 { ds.at(0) } else { ds }
+ if dinh.len() < 3 { panic("duong-tron-ngoai-tiep: cần ít nhất 3 đỉnh") }
+ let (O, r) = tron-qua-diem(dinh)
+ duong-tron(ctx, O, r, mau: mau, day: day, dut: dut, to: to)
+ if canh { da-giac(ctx, dinh, mau: mau-canh, day: 1.1pt) }
+ if ban-kinh {
+ // đỉnh nào có bán kính nằm THOÁNG nhất (không trùng cạnh đa giác)
+ let i = if dinh-r != auto { dinh-r } else {
+ let d = dinh.map(V => _cach-canh(trung-diem(O, V), dinh))
+ let tot = calc.max(..d)
+ d.position(x => x == tot)
+ }
+ let V = dinh.at(i)
+ doan(ctx, O, V, mau: mau, day: 0.8pt, dut: true,
+ ten: ten-r, mau-ten: mau)
+ }
+ if ten-tam != none {
+ diem(ctx, O, ten: ten-tam,
+ huong: if huong-tam == auto { _huong-thoang(O, dinh, r) } else { huong-tam })
+ }
+}
+
+// Đường tròn nội tiếp tam giác ABC.
+#let duong-tron-noi-tiep(ctx, A, B, C, ten-tam: $I$, mau: orange.darken(10%), day: 1pt, dut: false, ban-kinh: false) = {
+ let (I, r) = tam-noi-tiep(A, B, C)
+ duong-tron(ctx, I, r, mau: mau, day: day, dut: dut)
+ if ten-tam != none { diem(ctx, I, ten: ten-tam, huong: "tren") }
+ if ban-kinh {
+ let H = hinh-chieu(I, A, B)
+ doan(ctx, I, H, mau: mau, day: 0.8pt, dut: true)
+ goc-vuong(ctx, H, I, A, r: 0.22, mau: mau)
+ }
+}
+
+// ---------- Trực tâm ----------
+// Ba đường cao của tam giác ABC + trực tâm H.
+// ten : nhãn trực tâm (none = không ghi)
+// ten-chan : nhãn 3 chân đường cao theo thứ tự hạ từ A, B, C
+// (none = không ghi; mặc định $H_A$, $H_B$, $H_C$)
+// vuong : vẽ ký hiệu góc vuông tại chân đường cao
+// canh : true = vẽ luôn tam giác ABC (kèm nhãn đỉnh)
+#let ve-truc-tam(
+ ctx, A, B, C,
+ ten: $H$,
+ ten-chan: ($H_A$, $H_B$, $H_C$),
+ ten-dinh: ($A$, $B$, $C$),
+ canh: true,
+ mau: black, mau-cao: red, day: 1.1pt, dut: false, vuong: true,
+) = {
+ if canh { tam-giac(ctx, A, B, C, ten: ten-dinh, mau: mau, day: day) }
+ let H = truc-tam(A, B, C)
+ for (i, (P, X, Y)) in ((A, B, C), (B, C, A), (C, A, B)).enumerate() {
+ let K = hinh-chieu(P, X, Y)
+ // kéo dài tới chân đường cao (tam giác tù thì chân nằm ngoài cạnh)
+ doan(ctx, P, K, mau: mau-cao, day: 0.9pt, dut: dut)
+ // nối dài cạnh nếu chân đường cao rơi ra ngoài đoạn XY
+ let t = (
+ ((K.at(0) - X.at(0)) * (Y.at(0) - X.at(0))
+ + (K.at(1) - X.at(1)) * (Y.at(1) - X.at(1)))
+ / calc.pow(khoang-cach(X, Y), 2)
+ )
+ if t < 0 { doan(ctx, X, K, mau: mau, day: 0.7pt, dut: true) }
+ if t > 1 { doan(ctx, Y, K, mau: mau, day: 0.7pt, dut: true) }
+ if vuong { goc-vuong(ctx, K, P, if t < 0.5 { Y } else { X }, r: 0.22, mau: mau-cao) }
+ if ten-chan != none {
+ diem(ctx, K, bk: 1.6pt, mau: mau-cao, ten: ten-chan.at(i),
+ huong: huong-ra(K, trong-tam(A, B, C)))
+ }
+ }
+ if ten != none {
+ diem(ctx, H, mau: mau-cao, ten: ten, huong: huong-ra(H, trong-tam(A, B, C)))
+ } else {
+ diem(ctx, H, mau: mau-cao)
+ }
+}
+
+// ---------- Đường tròn bàng tiếp ----------
+// Đường tròn bàng tiếp TRONG GÓC A của tam giác ABC (tiếp xúc cạnh BC và
+// phần kéo dài của AB, AC). Muốn góc B thì gọi duong-tron-bang-tiep(ctx, B, C, A).
+// ten-tam : nhãn tâm (mặc định $J$)
+// keo-dai : vẽ nét đứt kéo dài hai cạnh AB, AC tới tiếp điểm
+// tiep-diem : chấm 3 tiếp điểm
+// ban-kinh : vẽ bán kính tới cạnh BC (kèm ký hiệu vuông góc)
+#let duong-tron-bang-tiep(
+ ctx, A, B, C,
+ ten-tam: $J$, ten-tiep: none,
+ mau: green.darken(25%), day: 1pt,
+ keo-dai: true, tiep-diem: true, ban-kinh: false,
+) = {
+ let (J, r) = tam-bang-tiep(A, B, C)
+ let a = khoang-cach(B, C)
+ let b = khoang-cach(C, A)
+ let c = khoang-cach(A, B)
+ let s = (a + b + c) / 2
+ let X = chia(B, C, (s - c) / a) // tiếp điểm trên cạnh BC
+ let Y = chia(A, B, s / c) // trên tia AB (ngoài B)
+ let Z = chia(A, C, s / b) // trên tia AC (ngoài C)
+ if keo-dai {
+ doan(ctx, B, Y, mau: mau, day: 0.7pt, dut: true)
+ doan(ctx, C, Z, mau: mau, day: 0.7pt, dut: true)
+ }
+ duong-tron(ctx, J, r, mau: mau, day: day)
+ if tiep-diem {
+ for (i, P) in (X, Y, Z).enumerate() {
+ diem(ctx, P, bk: 1.6pt, mau: mau,
+ ten: if ten-tiep == none { none } else { ten-tiep.at(i) },
+ huong: huong-ra(P, J))
+ }
+ }
+ if ban-kinh {
+ doan(ctx, J, X, mau: mau, day: 0.8pt, dut: true)
+ goc-vuong(ctx, X, J, B, r: 0.22, mau: mau)
+ }
+ if ten-tam != none { diem(ctx, J, mau: mau, ten: ten-tam, huong: huong-ra(J, A)) }
+}
+
+// ---------- Tiếp tuyến ----------
+// Hai tiếp tuyến kẻ từ điểm M ngoài đường tròn (O; r).
+#let tiep-tuyen-tu-diem(
+ ctx, O, r, M,
+ ten-tam: $O$, ten-diem: $M$, ten-tiep: ($T_1$, $T_2$),
+ mau: black, mau-tt: red, day: 1pt,
+) = {
+ duong-tron(ctx, O, r, mau: mau, day: day)
+ let (T1, T2) = tiep-diem(O, r, M)
+ doan(ctx, M, T1, mau: mau-tt, day: day)
+ doan(ctx, M, T2, mau: mau-tt, day: day)
+ doan(ctx, O, T1, mau: mau, day: 0.8pt, dut: true)
+ doan(ctx, O, T2, mau: mau, day: 0.8pt, dut: true)
+ doan(ctx, O, M, mau: mau, day: 0.8pt, dut: true)
+ goc-vuong(ctx, T1, O, M, r: 0.24, mau: mau-tt)
+ goc-vuong(ctx, T2, O, M, r: 0.24, mau: mau-tt)
+ if ten-tam != none { diem(ctx, O, ten: ten-tam, huong: "duoi") }
+ if ten-diem != none { diem(ctx, M, ten: ten-diem, huong: "phai") }
+ if ten-tiep != none {
+ diem(ctx, T1, ten: ten-tiep.at(0), huong: huong-ra(T1, O))
+ diem(ctx, T2, ten: ten-tiep.at(1), huong: huong-ra(T2, O))
+ }
+}
+
+// ---------- Tiếp tuyến TẠI một điểm trên đường tròn ----------
+// Dựng tiếp tuyến của đường tròn (O; r) TẠI điểm M (M nằm trên đường tròn):
+// đoạn thẳng qua M, VUÔNG GÓC với bán kính OM.
+// dai : nửa độ dài đoạn tiếp tuyến (auto = 1.4·r); đơn vị toạ độ toán.
+// mau / day / dut : kiểu nét của đoạn tiếp tuyến.
+// M không cần chính xác trên đường tròn — hướng tiếp tuyến lấy theo OM thực tế.
+/// Tiếp tuyến của (O; r) TẠI điểm M: đoạn qua M vuông góc OM.
+/// `tiep-tuyen-tai-diem(O, r, M, dai: auto, mau: black, day: 1pt, dut: false)`.
+#let tiep-tuyen-tai-diem(ctx, O, r, M, dai: auto, mau: black, day: 1pt, dut: false) = {
+ let dx = M.at(0) - O.at(0)
+ let dy = M.at(1) - O.at(1)
+ let l = calc.sqrt(dx * dx + dy * dy)
+ if l < 1e-9 { panic("tiep-tuyen-tai-diem: M trùng tâm O") }
+ // vectơ đơn vị VUÔNG GÓC với OM (hướng tiếp tuyến)
+ let tx = -dy / l
+ let ty = dx / l
+ let d = if dai == auto { r * 1.4 } else { dai }
+ let P1 = (M.at(0) - d * tx, M.at(1) - d * ty)
+ let P2 = (M.at(0) + d * tx, M.at(1) + d * ty)
+ doan(ctx, P1, P2, mau: mau, day: day, dut: dut)
+}
+
+// Giao điểm ĐƯỜNG THẲNG AB với ĐƯỜNG TRÒN (O; r).
+// duong : cặp điểm (A, B) xác định đường thẳng
+// tron : cặp (O, r) — tâm và bán kính
+// TRẢ VỀ MẢNG điểm giao, sắp theo chiều A -> B:
+// () rỗng nếu không cắt · một điểm (P,) nếu TIẾP XÚC · (P1, P2) nếu cắt.
+// let gd = giao-duong-thang-duong-tron((A, B), (O, r))
+#let giao-duong-thang-duong-tron(duong, tron) = {
+ let (A, B) = duong
+ let (O, r) = tron
+ let dx = B.at(0) - A.at(0)
+ let dy = B.at(1) - A.at(1)
+ let l = calc.sqrt(dx * dx + dy * dy)
+ if l < 1e-9 { panic("giao-duong-thang-duong-tron: A trùng B") }
+ let ux = dx / l
+ let uy = dy / l
+ // chân đường vuông góc H từ O xuống AB
+ let t = (O.at(0) - A.at(0)) * ux + (O.at(1) - A.at(1)) * uy
+ let H = (A.at(0) + t * ux, A.at(1) + t * uy)
+ let d2 = calc.pow(khoang-cach(O, H), 2)
+ let r2 = r * r
+ if d2 > r2 + 1e-9 { return () } // không cắt
+ let h = calc.sqrt(calc.max(0, r2 - d2))
+ if h < 1e-9 { return ((H.at(0), H.at(1)),) } // tiếp xúc
+ (
+ (H.at(0) - h * ux, H.at(1) - h * uy),
+ (H.at(0) + h * ux, H.at(1) + h * uy),
+ )
+}
+
+// Giao điểm hai đường tròn (O1;r1), (O2;r2): trả về (P1, P2).
+#let giao-hai-duong-tron(O1, r1, O2, r2) = {
+ let d = khoang-cach(O1, O2)
+ let a = (d * d + r1 * r1 - r2 * r2) / (2 * d)
+ let h = calc.sqrt(r1 * r1 - a * a)
+ let ux = (O2.at(0) - O1.at(0)) / d
+ let uy = (O2.at(1) - O1.at(1)) / d
+ let H = (O1.at(0) + a * ux, O1.at(1) + a * uy)
+ (
+ (H.at(0) - h * uy, H.at(1) + h * ux),
+ (H.at(0) + h * uy, H.at(1) - h * ux),
+ )
+}
+
+// Giao điểm hai đường thẳng AB và CD (giả sử không song song).
+#let giao-duong-thang(A, B, C, D) = {
+ let x1 = A.at(0); let y1 = A.at(1)
+ let x2 = B.at(0); let y2 = B.at(1)
+ let x3 = C.at(0); let y3 = C.at(1)
+ let x4 = D.at(0); let y4 = D.at(1)
+ let m = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
+ (
+ ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / m,
+ ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / m,
+ )
+}
+
+// ---------- Đường tròn lượng giác (hình hoàn chỉnh, tự tạo khung) ----------
+// goc: góc của điểm M trên đường tròn; hien: hiển thị hình chiếu sin/cos.
+#let duong-tron-luong-giac(
+ w: 7cm,
+ so-do: 55deg, // số đo góc của điểm M
+ ten-goc: $alpha$,
+ hien-chieu: true,
+ mau-tron: blue,
+ mau-goc: red,
+) = hinh(w: w, xmin: -1.55, xmax: 1.55, ymin: -1.5, ymax: 1.5, ctx => {
+ // hai trục
+ mui-ten(ctx, (-1.4, 0), (1.4, 0), day: 0.9pt)
+ mui-ten(ctx, (0, -1.35), (0, 1.35), day: 0.9pt)
+ nhan(ctx, (1.4, 0), $x$, huong: "duoi")
+ nhan(ctx, (0, 1.35), $y$, huong: "phai")
+ duong-tron(ctx, (0, 0), 1, mau: mau-tron, day: 1.2pt)
+ // các điểm đặc biệt
+ nhan(ctx, (1, 0), $1$, huong: "duoi-phai", cach: 4pt)
+ nhan(ctx, (-1, 0), $-1$, huong: "duoi-trai", cach: 4pt)
+ nhan(ctx, (0, 1), $1$, huong: "tren-trai", cach: 4pt)
+ nhan(ctx, (0, -1), $-1$, huong: "duoi-trai", cach: 4pt)
+ diem(ctx, (0, 0), ten: $O$, huong: "duoi-trai", bk: 1.6pt)
+ // điểm M và bán kính OM
+ let M = (calc.cos(so-do), calc.sin(so-do))
+ doan(ctx, (0, 0), M, mau: mau-goc, day: 1.2pt)
+ diem(ctx, M, ten: $M$, huong: huong-ra(M, (0, 0)), mau: mau-goc)
+ goc(ctx, (0, 0), (1, 0), M, r: 0.3, ten: ten-goc, mau: mau-goc)
+ if hien-chieu {
+ doan(ctx, M, (M.at(0), 0), mau: gray, day: 0.8pt, dut: true)
+ doan(ctx, M, (0, M.at(1)), mau: gray, day: 0.8pt, dut: true)
+ diem(ctx, (M.at(0), 0), ten: $cos alpha$, huong: "duoi", bk: 1.6pt, mau: mau-goc)
+ diem(ctx, (0, M.at(1)), ten: $sin alpha$, huong: "trai", bk: 1.6pt, mau: mau-goc)
+ }
+})
diff --git a/packages/preview/conic-toan/0.3.4/lib/khao-sat.typ b/packages/preview/conic-toan/0.3.4/lib/khao-sat.typ
new file mode 100644
index 0000000000..3f48628ce6
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/khao-sat.typ
@@ -0,0 +1,354 @@
+// =====================================================================
+// khao-sat.typ — KHẢO SÁT & VẼ ĐỒ THỊ TỰ ĐỘNG TỪ HỆ SỐ
+//
+// 5 hàm, chỉ cần nhập hệ số (như bbt-*/do-thi-*) là xổ ra TRỌN lời giải
+// (tập xác định, đạo hàm, giới hạn, chiều biến thiên, cực trị / tiệm cận,
+// bảng biến thiên) kèm ĐỒ THỊ — bao quát mọi trường hợp của mỗi hàm:
+//
+// #khao-sat-ve-do-thi-ham-bac-hai(a, b, c) y = ax² + bx + c
+// #khao-sat-ve-do-thi-ham-bac-ba(a, b, c, d) y = ax³ + bx² + cx + d
+// #khao-sat-ve-do-thi-ham-trung-phuong(a, b, c) y = ax⁴ + bx² + c
+// #khao-sat-ve-do-thi-ham-phan-thuc(a, b, c, d) y = (ax + b)/(cx + d)
+// #khao-sat-ve-do-thi-ham-huu-ti(a, b, c, d, e) y = (ax² + bx + c)/(dx + e)
+//
+// Tham số chung:
+// tieu-de: auto -> dòng đậm "Khảo sát sự biến thiên và vẽ đồ thị của
+// hàm số y = ..."; none -> bỏ tiêu đề; hoặc nội dung tuỳ ý.
+// w: bề rộng đồ thị (mặc định 7.6cm). giua: true -> canh giữa BBT + đồ thị.
+// =====================================================================
+#import "do-thi.typ": so-toan, so-dep, so-can-thuc, cuc-tri-bac-ba, cuc-tri-huu-ti, do-thi-bac-hai, do-thi-bac-ba, do-thi-trung-phuong, do-thi-phan-thuc, do-thi-huu-ti
+#import "bang.typ": bbt-bac-hai, bbt-bac-ba, bbt-trung-phuong, bbt-phan-thuc, bbt-huu-ti
+
+#let _eps = 0.000001
+#let _ninf = $-oo$
+#let _pinf = $+oo$
+
+// ----- Đơn thức |co|·x^mu (không kèm dấu) -----
+#let _don-thuc(co, mu) = {
+ let mag = calc.abs(co)
+ let bien = if mu == 0 { none } else if mu == 1 { $x$ } else { $x^#mu$ }
+ if bien == none { so-toan(mag) }
+ else if calc.abs(mag - 1) < _eps { bien }
+ else { $#so-toan(mag) #bien$ }
+}
+
+// ----- Đa thức: danh sách (hệ số, số mũ) giảm dần -> nội dung math -----
+#let _da-thuc(cac) = {
+ let cac = cac.filter(t => calc.abs(t.at(0)) > _eps)
+ if cac.len() == 0 { return $0$ }
+ let acc = $0$
+ for (i, t) in cac.enumerate() {
+ let (co, mu) = t
+ let than = _don-thuc(co, mu)
+ if i == 0 {
+ acc = if co < 0 { $-#than$ } else { than }
+ } else {
+ acc = if co < 0 { $#acc - #than$ } else { $#acc + #than$ }
+ }
+ }
+ acc
+}
+
+// ----- Khoảng (a; b) — a, b là nội dung math; dùng TRONG MARKUP: #_kh(..) -----
+#let _kh(a, b) = $(#a ; #b)$
+
+// ----- Dòng tiêu đề "Khảo sát ... y = " -----
+#let _tieu-de(tieu-de, bt) = {
+ if tieu-de == none { return none }
+ let nd = if tieu-de == auto { [Khảo sát sự biến thiên và vẽ đồ thị của hàm số $y = #bt$] } else { tieu-de }
+ [*#nd* ]
+}
+
+// ----- Khối canh giữa (hoặc để nguyên) cho BBT/đồ thị -----
+#let _o(giua, than) = if giua { align(center, than) } else { than }
+
+// =====================================================================
+// 1) BẬC HAI: y = ax² + bx + c
+// =====================================================================
+#let khao-sat-ve-do-thi-ham-bac-hai(a, b, c, tieu-de: auto, w: 7.6cm, giua: false) = {
+ assert(calc.abs(a) > _eps, message: "bac-hai: cần a ≠ 0")
+ let bt = _da-thuc(((a, 2), (b, 1), (c, 0)))
+ let xd = -b / (2 * a) // hoành độ đỉnh
+ let yd = c - b * b / (4 * a) // tung độ đỉnh
+ let dlt = b * b - 4 * a * c
+ let Xd = so-toan(xd)
+ let Yd = so-toan(yd)
+ let cbt = if a > 0 {
+ [Hàm số nghịch biến trên khoảng #_kh(_ninf, Xd) và đồng biến trên khoảng #_kh(Xd, _pinf)#"."]
+ } else {
+ [Hàm số đồng biến trên khoảng #_kh(_ninf, Xd) và nghịch biến trên khoảng #_kh(Xd, _pinf)#"."]
+ }
+ let ct = if a > 0 {
+ [Hàm số đạt cực tiểu tại $x = #Xd$, $y_("CT") = #Yd$; không có cực đại.]
+ } else {
+ [Hàm số đạt cực đại tại $x = #Xd$, $y_("CĐ") = #Yd$; không có cực tiểu.]
+ }
+ let gh = if a > 0 {
+ [$limits(lim)_(x -> -oo) y = +oo$; $limits(lim)_(x -> +oo) y = +oo$.]
+ } else {
+ [$limits(lim)_(x -> -oo) y = -oo$; $limits(lim)_(x -> +oo) y = -oo$.]
+ }
+ let giao-ox = if dlt > _eps {
+ let s = calc.sqrt(dlt) / (2 * calc.abs(a))
+ [cắt trục hoành tại hai điểm $(#so-toan(xd - s) ; 0)$ và $(#so-toan(xd + s) ; 0)$]
+ } else if dlt < -_eps {
+ [không cắt trục hoành]
+ } else {
+ [tiếp xúc trục hoành tại $(#Xd ; 0)$]
+ }
+ [
+ #_tieu-de(tieu-de, bt)
+ - Tập xác định: $D = RR$.
+ - Sự biến thiên:
+ + Đạo hàm: $y' = #_da-thuc(((2 * a, 1), (b, 0)))$; $y' = 0 <=> x = #Xd$.
+ + Giới hạn: #gh
+ + Chiều biến thiên: #cbt
+ + Cực trị: #ct
+ + Bảng biến thiên:
+ #_o(giua, bbt-bac-hai(a, b, c))
+ - Đồ thị:
+ + Đỉnh $I(#Xd ; #Yd)$, trục đối xứng $x = #Xd$.
+ + Đồ thị cắt trục tung tại $(0; #so-toan(c))$ và #giao-ox.
+ #_o(giua, do-thi-bac-hai(a, b, c, w: w))
+ ]
+}
+
+// =====================================================================
+// 2) BẬC BA: y = ax³ + bx² + cx + d (Δ' = b² − 3ac)
+// =====================================================================
+#let khao-sat-ve-do-thi-ham-bac-ba(a, b, c, d, tieu-de: auto, w: 7.6cm, giua: false) = {
+ assert(calc.abs(a) > _eps, message: "bac-ba: cần a ≠ 0")
+ let bt = _da-thuc(((a, 3), (b, 2), (c, 1), (d, 0)))
+ let f = x => ((a * x + b) * x + c) * x + d
+ let delta = b * b - 3 * a * c
+ let xu = -b / (3 * a) // hoành độ điểm uốn
+ let yu = f(xu)
+ let gh = if a > 0 {
+ [$limits(lim)_(x -> -oo) y = -oo$; $limits(lim)_(x -> +oo) y = +oo$.]
+ } else {
+ [$limits(lim)_(x -> -oo) y = +oo$; $limits(lim)_(x -> +oo) y = -oo$.]
+ }
+ let dao = _da-thuc(((3 * a, 2), (2 * b, 1), (c, 0)))
+ let cbt = []
+ let ct = []
+ let dao-them = []
+ if delta > _eps {
+ // hai cực trị — hoành độ & giá trị CHÍNH XÁC dạng căn thức
+ let ctr = cuc-tri-bac-ba(a, b, c, d)
+ let U = ctr.x1
+ let V = ctr.x2
+ let Fu = ctr.y1
+ let Fv = ctr.y2
+ dao-them = [ $y' = 0 <=> x = #U$ hoặc $x = #V$.]
+ if a > 0 {
+ cbt = [Hàm số đồng biến trên các khoảng #_kh(_ninf, U) và #_kh(V, _pinf)#"; "nghịch biến trên khoảng #_kh(U, V)#"."]
+ ct = [Hàm số đạt cực đại tại $x = #U$, $y_("CĐ") = #Fu$; đạt cực tiểu tại $x = #V$, $y_("CT") = #Fv$.]
+ } else {
+ cbt = [Hàm số nghịch biến trên các khoảng #_kh(_ninf, U) và #_kh(V, _pinf)#"; "đồng biến trên khoảng #_kh(U, V)#"."]
+ ct = [Hàm số đạt cực tiểu tại $x = #U$, $y_("CT") = #Fu$; đạt cực đại tại $x = #V$, $y_("CĐ") = #Fv$.]
+ }
+ } else if delta < -_eps {
+ // đơn điệu, y' vô nghiệm
+ dao-them = [ Vì $Delta' = #so-toan(delta) < 0$ nên $y'$ không đổi dấu.]
+ cbt = if a > 0 {
+ [Hàm số đồng biến trên khoảng #_kh(_ninf, _pinf)#"."]
+ } else {
+ [Hàm số nghịch biến trên khoảng #_kh(_ninf, _pinf)#"."]
+ }
+ ct = [Hàm số không có cực trị.]
+ } else {
+ // Δ' = 0: nghiệm kép, vẫn đơn điệu
+ dao-them = [ $y' = 0 <=> x = #so-toan(xu)$ (nghiệm kép), $y'$ không đổi dấu.]
+ cbt = if a > 0 {
+ [Hàm số đồng biến trên khoảng #_kh(_ninf, _pinf)#"."]
+ } else {
+ [Hàm số nghịch biến trên khoảng #_kh(_ninf, _pinf)#"."]
+ }
+ ct = [Hàm số không có cực trị.]
+ }
+ [
+ #_tieu-de(tieu-de, bt)
+ - Tập xác định: $D = RR$.
+ - Sự biến thiên:
+ + Đạo hàm: $y' = #dao$.#dao-them
+ + Giới hạn: #gh
+ + Chiều biến thiên: #cbt
+ + Cực trị: #ct
+ + Bảng biến thiên:
+ #_o(giua, bbt-bac-ba(a, b, c, d))
+ - Đồ thị:
+ + Đạo hàm cấp hai: $y'' = #_da-thuc(((6 * a, 1), (2 * b, 0)))$; $y'' = 0 <=> x = #so-toan(xu)$. Điểm uốn $I(#so-toan(xu) ; #so-toan(yu))$.
+ + Đồ thị cắt trục tung tại $(0; #so-toan(d))$.
+ #_o(giua, do-thi-bac-ba(a, b, c, d, w: w))
+ ]
+}
+
+// =====================================================================
+// 3) TRÙNG PHƯƠNG: y = ax⁴ + bx² + c (a·b < 0 -> 3 cực trị)
+// =====================================================================
+#let khao-sat-ve-do-thi-ham-trung-phuong(a, b, c, tieu-de: auto, w: 7.6cm, giua: false) = {
+ assert(calc.abs(a) > _eps, message: "trung-phuong: cần a ≠ 0")
+ let bt = _da-thuc(((a, 4), (b, 2), (c, 0)))
+ let f = x => a * calc.pow(x, 4) + b * x * x + c
+ let dao = _da-thuc(((4 * a, 3), (2 * b, 1)))
+ let gh = if a > 0 {
+ [$limits(lim)_(x -> -oo) y = +oo$; $limits(lim)_(x -> +oo) y = +oo$.]
+ } else {
+ [$limits(lim)_(x -> -oo) y = -oo$; $limits(lim)_(x -> +oo) y = -oo$.]
+ }
+ let cbt = []
+ let ct = []
+ let dao-them = []
+ if a * b < -_eps {
+ // 3 cực trị: x = 0, x = ±xc
+ let Xc = so-can-thuc(0, 1, -b / (2 * a), 1)
+ let nXc = so-can-thuc(0, -1, -b / (2 * a), 1)
+ let yc = so-can-thuc(4 * a * c - b * b, 0, 0, 4 * a)
+ let y0 = so-toan(c)
+ dao-them = [ $y' = 0 <=> x = 0$ hoặc $x = plus.minus #Xc$.]
+ if a > 0 {
+ cbt = [Hàm số nghịch biến trên các khoảng #_kh(_ninf, nXc) và #_kh($0$, Xc)#"; "đồng biến trên các khoảng #_kh(nXc, $0$) và #_kh(Xc, _pinf)#"."]
+ ct = [Hàm số đạt cực tiểu tại $x = plus.minus #Xc$, $y_("CT") = #yc$; đạt cực đại tại $x = 0$, $y_("CĐ") = #y0$.]
+ } else {
+ cbt = [Hàm số đồng biến trên các khoảng #_kh(_ninf, nXc) và #_kh($0$, Xc)#"; "nghịch biến trên các khoảng #_kh(nXc, $0$) và #_kh(Xc, _pinf)#"."]
+ ct = [Hàm số đạt cực đại tại $x = plus.minus #Xc$, $y_("CĐ") = #yc$; đạt cực tiểu tại $x = 0$, $y_("CT") = #y0$.]
+ }
+ } else {
+ // 1 cực trị tại x = 0
+ dao-them = [ $y' = 0 <=> x = 0$.]
+ if a > 0 {
+ cbt = [Hàm số nghịch biến trên khoảng #_kh(_ninf, $0$) và đồng biến trên khoảng #_kh($0$, _pinf)#"."]
+ ct = [Hàm số đạt cực tiểu tại $x = 0$, $y_("CT") = #so-toan(c)$; không có cực đại.]
+ } else {
+ cbt = [Hàm số đồng biến trên khoảng #_kh(_ninf, $0$) và nghịch biến trên khoảng #_kh($0$, _pinf)#"."]
+ ct = [Hàm số đạt cực đại tại $x = 0$, $y_("CĐ") = #so-toan(c)$; không có cực tiểu.]
+ }
+ }
+ [
+ #_tieu-de(tieu-de, bt)
+ - Tập xác định: $D = RR$.
+ - Sự biến thiên:
+ + Đạo hàm: $y' = #dao$.#dao-them
+ + Giới hạn: #gh
+ + Chiều biến thiên: #cbt
+ + Cực trị: #ct
+ + Bảng biến thiên:
+ #_o(giua, bbt-trung-phuong(a, b, c))
+ - Đồ thị:
+ + Đồ thị cắt trục tung tại $(0; #so-toan(c))$; là hàm số chẵn nên nhận trục $O y$ làm trục đối xứng.
+ #_o(giua, do-thi-trung-phuong(a, b, c, w: w))
+ ]
+}
+
+// =====================================================================
+// 4) PHÂN THỨC BẬC NHẤT/BẬC NHẤT: y = (ax + b)/(cx + d)
+// =====================================================================
+#let khao-sat-ve-do-thi-ham-phan-thuc(a, b, c, d, tieu-de: auto, w: 7.6cm, giua: false) = {
+ assert(calc.abs(c) > _eps, message: "phan-thuc: cần c ≠ 0")
+ let D = a * d - b * c
+ assert(calc.abs(D) > _eps, message: "phan-thuc: ad − bc = 0 (hàm hằng)")
+ let tu = _da-thuc(((a, 1), (b, 0)))
+ let mau = _da-thuc(((c, 1), (d, 0)))
+ let bt = $(#tu)/(#mau)$
+ let x0 = -d / c // TCĐ
+ let y0 = a / c // TCN
+ let X0 = so-toan(x0)
+ let Y0 = so-toan(y0)
+ let dong = D > 0
+ let dao = if dong {
+ $y' = #so-toan(D)/(#mau)^2 > 0, forall x != #X0$
+ } else {
+ $y' = #so-toan(D)/(#mau)^2 < 0, forall x != #X0$
+ }
+ let cbt = if dong {
+ [Hàm số đồng biến trên từng khoảng #_kh(_ninf, X0) và #_kh(X0, _pinf)#"."]
+ } else {
+ [Hàm số nghịch biến trên từng khoảng #_kh(_ninf, X0) và #_kh(X0, _pinf)#"."]
+ }
+ let trai = if dong { _ninf } else { _pinf }
+ let phai = if dong { _pinf } else { _ninf }
+ [
+ #_tieu-de(tieu-de, bt)
+ - Tập xác định: $D = RR without {#X0}$.
+ - Sự biến thiên:
+ + Chiều biến thiên: #dao. #cbt
+ + Cực trị: Hàm số không có cực trị.
+ + Tiệm cận:
+ $limits(lim)_(x -> #X0^-) y = #trai$, $limits(lim)_(x -> #X0^+) y = #phai$ nên đường thẳng $x = #X0$ là tiệm cận đứng.
+ $limits(lim)_(x -> plus.minus oo) y = #Y0$ nên đường thẳng $y = #Y0$ là tiệm cận ngang.
+ + Bảng biến thiên:
+ #_o(giua, bbt-phan-thuc(a, b, c, d))
+ - Đồ thị:
+ + Đồ thị nhận điểm $I(#X0 ; #Y0)$ (giao hai tiệm cận) làm tâm đối xứng.
+ #_o(giua, do-thi-phan-thuc(a, b, c, d, w: w))
+ ]
+}
+
+// =====================================================================
+// 5) HỮU TỈ BẬC HAI/BẬC NHẤT: y = (ax² + bx + c)/(dx + e)
+// Viết y = px + q + r/(dx + e); p = a/d, q = (bd − ae)/d², r = c − qe.
+// =====================================================================
+#let khao-sat-ve-do-thi-ham-huu-ti(a, b, c, d, e, tieu-de: auto, w: 7.6cm, giua: false) = {
+ assert(calc.abs(a) > _eps and calc.abs(d) > _eps, message: "huu-ti: cần a ≠ 0 và d ≠ 0")
+ let tu = _da-thuc(((a, 2), (b, 1), (c, 0)))
+ let mau = _da-thuc(((d, 1), (e, 0)))
+ let bt = $(#tu)/(#mau)$
+ let x0 = -e / d
+ let p = a / d
+ let q = (b * d - a * e) / (d * d)
+ let r = c - q * e
+ let f = x => (a * x * x + b * x + c) / (d * x + e)
+ let X0 = so-toan(x0)
+ let tcx = _da-thuc(((p, 1), (q, 0))) // y = px + q
+ let co-ct = p != 0 and r * d * p > _eps
+ // y' = p + k/(dx+e)² với k = −rd
+ let k = -r * d
+ let dao = if k > _eps { $y' = #so-toan(p) + #so-toan(k)/(#mau)^2$ }
+ else if k < -_eps { $y' = #so-toan(p) - #so-toan(-k)/(#mau)^2$ }
+ else { $y' = #so-toan(p)$ }
+ let cbt = []
+ let ct = []
+ let dao-them = []
+ if co-ct {
+ // hoành độ & giá trị cực trị CHÍNH XÁC dạng căn thức
+ let ctr = cuc-tri-huu-ti(a, b, c, d, e)
+ let U = ctr.x1
+ let V = ctr.x2
+ let Fu = ctr.y1
+ let Fv = ctr.y2
+ dao-them = [ $y' = 0 <=> x = #U$ hoặc $x = #V$.]
+ if p > 0 {
+ cbt = [Hàm số đồng biến trên các khoảng #_kh(_ninf, U) và #_kh(V, _pinf)#"; "nghịch biến trên các khoảng #_kh(U, X0) và #_kh(X0, V)#"."]
+ ct = [Hàm số đạt cực đại tại $x = #U$, $y_("CĐ") = #Fu$; đạt cực tiểu tại $x = #V$, $y_("CT") = #Fv$.]
+ } else {
+ cbt = [Hàm số nghịch biến trên các khoảng #_kh(_ninf, U) và #_kh(V, _pinf)#"; "đồng biến trên các khoảng #_kh(U, X0) và #_kh(X0, V)#"."]
+ ct = [Hàm số đạt cực tiểu tại $x = #U$, $y_("CT") = #Fu$; đạt cực đại tại $x = #V$, $y_("CĐ") = #Fv$.]
+ }
+ } else {
+ dao-them = [ (không đổi dấu trên tập xác định).]
+ if p > 0 {
+ cbt = [Hàm số đồng biến trên từng khoảng #_kh(_ninf, X0) và #_kh(X0, _pinf)#"."]
+ } else {
+ cbt = [Hàm số nghịch biến trên từng khoảng #_kh(_ninf, X0) và #_kh(X0, _pinf)#"."]
+ }
+ ct = [Hàm số không có cực trị.]
+ }
+ let trai = if r * d > 0 { _ninf } else { _pinf }
+ let phai = if r * d > 0 { _pinf } else { _ninf }
+ [
+ #_tieu-de(tieu-de, bt)
+ - Tập xác định: $D = RR without {#X0}$.
+ - Sự biến thiên:
+ + Chiều biến thiên: #dao.#dao-them #cbt
+ + Cực trị: #ct
+ + Tiệm cận:
+ $limits(lim)_(x -> #X0^-) y = #trai$, $limits(lim)_(x -> #X0^+) y = #phai$ nên đường thẳng $x = #X0$ là tiệm cận đứng.
+ $limits(lim)_(x -> plus.minus oo) [y - (#tcx)] = 0$ nên đường thẳng $y = #tcx$ là tiệm cận xiên.
+ + Bảng biến thiên:
+ #_o(giua, bbt-huu-ti(a, b, c, d, e))
+ - Đồ thị:
+ + Đồ thị nhận giao hai tiệm cận làm tâm đối xứng.
+ #_o(giua, do-thi-huu-ti(a, b, c, d, e, w: w))
+ ]
+}
+// (đồng bộ mount 07/2026)
diff --git a/packages/preview/conic-toan/0.3.4/lib/mat-cong.typ b/packages/preview/conic-toan/0.3.4/lib/mat-cong.typ
new file mode 100644
index 0000000000..46aee9091a
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/mat-cong.typ
@@ -0,0 +1,471 @@
+// =====================================================================
+// mat-cong.typ — ENGINE MẶT CONG (nón · trụ) CÓ NÉT KHUẤT TỰ ĐỘNG (08/2026)
+//
+// Bổ khuyết cho `da-dien.typ`: engine kia chỉ lo được KHỐI ĐA DIỆN LỒI và
+// KHÔNG xử lý được hai khối che nhau. Ở đây dùng MỘT cơ chế duy nhất cho cả
+// hai việc — BẮN TIA từ điểm đang xét về phía người nhìn; tia gặp lòng bất
+// kì khối nào (kể cả chính nó) thì đoạn đó là NÉT KHUẤT.
+//
+// #mat-cong(
+// mat-non(r: 2, cao: 4, mau: red),
+// mat-tru(tam: (0, 2, 0), r: 2, cao: 4, mau: blue),
+// cam: chieu-truc-giao(ngang: 15deg, cao: 22deg),
+// truc: (x: 2.6, y: 4.4, z: 4.6),
+// )
+//
+// TRỤC KHỐI ĐẶT NGHIÊNG ĐƯỢC: `nghieng:` là góc giữa trục khối và Oz,
+// `huong:` là hướng ngả. Khi trục nghiêng thì TOÀN THÂN nghiêng theo — mặt
+// đáy luôn VUÔNG GÓC với trục, đường sinh nghiêng cùng trục:
+//
+// #mat-cong(mat-tru(r: 1.2, cao: 5, nghieng: 70deg)) // trụ nằm nghiêng
+// #mat-cong(mat-non(r: 1.6, cao: 4.5, nghieng: 90deg)) // nón nằm ngang
+//
+// Mọi công thức đều viết theo KHUNG RIÊNG (u, v, w) của khối — w là trục,
+// (u, v) là hai phương trong mặt đáy — nên trục đứng chỉ là trường hợp riêng.
+//
+// Phép thử che khuất là GIẢI TÍCH, KHÔNG quét mẫu: giao của tia với mặt đáy
+// là phương trình bậc nhất, với mặt bên là bậc hai; trạng thái trong/ngoài
+// chỉ đổi tại các nghiệm đó nên chỉ cần xét trung điểm giữa hai mốc liên
+// tiếp. Sai số duy nhất còn lại là ở mắt lưới của đường được vẽ (`n:`).
+//
+// GIỚI HẠN: đường sinh BIÊN (silhouette) CỐ Ý không để chính khối của nó
+// che — tia bắn từ đúng đường biên là tiếp tuyến, xét ở đó sẽ chập chờn làm
+// nét biên lúc liền lúc đứt. Chưa có mặt cầu.
+// =====================================================================
+#import "ve.typ": *
+#import "da-dien.typ": v3-cong, v3-tru, v3-nhan, v3-dai, v3-vo-huong, v3-co-huong, v3-chuan, _cam, chieu-3d, chieu-xien, chieu-oxyz, chieu-truc-giao
+
+// =====================================================================
+// KHUNG RIÊNG CỦA KHỐI — (u, v) trong mặt đáy, w dọc trục
+// =====================================================================
+// Camera MẶC ĐỊNH của engine này là chiếu TRỰC GIAO, KHÔNG phải `chieu-xien`
+// như `da-dien.typ`. Lý do: chiếu xiên làm đường tròn nằm ngang chiếu ra elip
+// NGHIÊNG trong khi trục khối vẫn dựng đứng — đáy nghiêng mà trục đứng, nhìn
+// vô lý. Chiếu trực giao cho elip đáy có trục lớn NẰM NGANG, khối vẽ ra NGAY
+// NGẮN đúng lối sách giáo khoa. Muốn lối cũ thì khai rõ `cam: chieu-xien()`.
+#let _cam-mc(c) = if c == auto {
+ chieu-truc-giao(ngang: 15deg, cao: 22deg)
+} else { _cam(c) }
+
+#let _khung(w0) = {
+ let w = v3-chuan(w0)
+ // chọn vectơ mồi KHÔNG song song với w, kẻo tích có hướng ra vectơ không
+ let moi = if calc.abs(w.at(2)) > 0.9 { (1.0, 0.0, 0.0) } else { (0.0, 0.0, 1.0) }
+ let u = v3-chuan(v3-co-huong(moi, w))
+ (u, v3-co-huong(w, u), w)
+}
+
+// Nhận CẢ kiểu angle (`30deg`) LẪN số trần (`30` = 30 ĐỘ) — cùng lối
+// `toa-cuc` của ve.typ. Không có dòng này thì `nghieng: 210` bị Typst hiểu
+// là 210 RADIAN, khối quay đi đâu không biết mà KHÔNG hề báo lỗi.
+#let _goc-do(a) = if type(a) == angle { a } else { a * 1deg }
+
+// Trục khối suy từ hai góc: `nghieng` = góc so với Oz, `huong` = hướng ngả.
+#let _truc-tu-goc(nghieng0, huong0) = {
+ let nghieng = _goc-do(nghieng0)
+ let huong = _goc-do(huong0)
+ (
+ calc.sin(nghieng) * calc.cos(huong),
+ calc.sin(nghieng) * calc.sin(huong),
+ calc.cos(nghieng),
+ )
+}
+
+#let _khoi(loai, tam, r, cao, nghieng, huong, truc, mau, to) = {
+ let w0 = if truc == auto { _truc-tu-goc(nghieng, huong) } else { truc }
+ let (u, v, w) = _khung(w0)
+ (loai: loai, tam: tam, r: r, cao: cao, u: u, v: v, w: w, mau: mau, to: to)
+}
+
+// =====================================================================
+// MÔ TẢ KHỐI — TRẢ GIÁ TRỊ (KHÔNG kê vào ve-voi)
+// tam : tâm mặt đáy (đáy DƯỚI nếu trục đứng)
+// r, cao : bán kính đáy, chiều cao ĐO DỌC TRỤC
+// nghieng : góc giữa trục khối và Oz (0 = đứng, 90° = nằm ngang)
+// huong : hướng ngả của trục, đo trong mặt phẳng Oxy
+// truc : ghi đè thẳng bằng vectơ 3D (auto = suy từ nghieng/huong)
+// =====================================================================
+#let mat-non(
+ tam: (0, 0, 0), r: 2, cao: 4,
+ nghieng: 0deg, huong: 0deg, truc: auto,
+ mau: auto, to: auto,
+) = _khoi("non", tam, r, cao, nghieng, huong, truc, mau, to)
+
+#let mat-tru(
+ tam: (0, 0, 0), r: 2, cao: 4,
+ nghieng: 0deg, huong: 0deg, truc: auto,
+ mau: auto, to: auto,
+) = _khoi("tru", tam, r, cao, nghieng, huong, truc, mau, to)
+
+// Đỉnh nón (cũng là tâm đáy trên của trụ).
+#let dinh-non(k) = v3-cong(k.tam, v3-nhan(k.cao, k.w))
+
+// Mảng điểm 3D của một ĐƯỜNG TRÒN tâm `tam`, bán kính r, nằm trong mặt phẳng
+// vuông góc với `truc` — đưa vào `duong:` của `ve-mat-cong` để vẽ thiết diện,
+// đường tròn phụ… mà vẫn được tự chia liền/đứt. TRẢ GIÁ TRỊ (không kê).
+#let tron-ngang(tam, r, truc: (0, 0, 1), n: 48) = {
+ let (u, v, w) = _khung(truc)
+ range(n + 1).map(i => {
+ let t = 360deg * (i / n)
+ v3-cong(tam, v3-cong(v3-nhan(r * calc.cos(t), u), v3-nhan(r * calc.sin(t), v)))
+ })
+}
+
+// Điểm P có nằm HẲN trong lòng khối không?
+// `lui` CO KHỐI LẠI một chút — KHÔNG phải để làm đẹp mà là BẮT BUỘC:
+// đường tròn được vẽ bằng đa giác n cạnh, nên TRUNG ĐIỂM DÂY CUNG (điểm mà
+// `_duong-tu-dong` đem đi thử) thụt vào trong vành đúng bằng độ võng
+// `r·(1 − cos(180°/n))`. Tia bắn từ đó xuyên qua một LÁT MỎNG của chính khối
+// rồi ra ngay ⇒ phép thử giải tích bắt đúng lát đó và báo KHUẤT oan, làm cả
+// vành đáy thành nét đứt. Vì vậy `ve-mat-cong` tính `lui` theo `n` rồi gắn
+// vào từng khối. (Bản quét mẫu cũ vô tình NHẢY QUA lát này nên không lộ lỗi.)
+#let _trong-khoi(k, P) = {
+ let lui = k.at("lui", default: 1e-4)
+ let q = v3-tru(P, k.tam)
+ let z = v3-vo-huong(q, k.w) // toạ độ DỌC TRỤC
+ if z < lui or z > k.cao - lui { return false }
+ let d = v3-dai(v3-tru(q, v3-nhan(z, k.w))) // khoảng cách tới trục
+ let bk = if k.loai == "non" { k.r * (1 - z / k.cao) } else { k.r }
+ d < bk - lui
+}
+
+// Các MỐC s > 0 mà trạng thái trong/ngoài khối k CÓ THỂ đổi dọc tia P + s·e:
+// hai mặt đáy (mặt phẳng) + mặt bên (phương trình bậc hai).
+// ⚠️ ĐỪNG quay lại lối QUÉT MẪU đều: bước quét luôn có thể dài hơn dây cung
+// đi qua khối ⇒ tia "nhảy qua" khối và nét khuất bị bỏ sót. Đã vấp đúng lỗi
+// đó (đường sinh trụ nằm trong lòng nón mà vẫn vẽ liền): bước 1.13 trong khi
+// dây cung chỉ 0.5.
+#let _moc-tia(k, P, e) = {
+ let q = v3-tru(P, k.tam)
+ let z0 = v3-vo-huong(q, k.w)
+ let ez = v3-vo-huong(e, k.w)
+ let a = v3-tru(q, v3-nhan(z0, k.w)) // phần vuông góc trục
+ let b = v3-tru(e, v3-nhan(ez, k.w))
+ let moc = ()
+ if calc.abs(ez) > 1e-12 {
+ moc.push(-z0 / ez)
+ moc.push((k.cao - z0) / ez)
+ }
+ // mặt bên: |a + s·b|² = bán kính(s)²
+ let he = if k.loai == "tru" {
+ (v3-vo-huong(b, b), 2 * v3-vo-huong(a, b), v3-vo-huong(a, a) - k.r * k.r)
+ } else {
+ let m = k.r / k.cao
+ let a0 = k.r - m * z0
+ let k1 = m * ez
+ (
+ v3-vo-huong(b, b) - k1 * k1,
+ 2 * (v3-vo-huong(a, b) + a0 * k1),
+ v3-vo-huong(a, a) - a0 * a0,
+ )
+ }
+ let (A, B, C) = he
+ if calc.abs(A) > 1e-12 {
+ let D = B * B - 4 * A * C
+ if D > 0 {
+ let sq = calc.sqrt(D)
+ moc.push((-B - sq) / (2 * A))
+ moc.push((-B + sq) / (2 * A))
+ }
+ } else if calc.abs(B) > 1e-12 {
+ moc.push(-C / B)
+ }
+ moc
+}
+
+// Điểm P có bị khối nào che không: bắn tia P + s·e về phía người nhìn.
+// CHÍNH XÁC (không quét mẫu): trạng thái trong/ngoài chỉ đổi tại các mốc
+// của `_moc-tia`, nên chỉ cần xét TRUNG ĐIỂM giữa hai mốc liên tiếp.
+// `bo` = chỉ số khối được BỎ QUA (không tự che).
+#let _bi-che(P, e, ds-khoi, bo: -1) = {
+ let moc = (1e-6,)
+ for j in range(ds-khoi.len()) {
+ if j == bo { continue }
+ for s in _moc-tia(ds-khoi.at(j), P, e) {
+ if s > 1e-6 { moc.push(s) }
+ }
+ }
+ let moc = moc.sorted()
+ for i in range(moc.len() - 1) {
+ let Q = v3-cong(P, v3-nhan((moc.at(i) + moc.at(i + 1)) / 2, e))
+ for j in range(ds-khoi.len()) {
+ if j != bo and _trong-khoi(ds-khoi.at(j), Q) { return true }
+ }
+ }
+ false
+}
+
+// =====================================================================
+// VẼ MỘT ĐƯỜNG 3D, TỰ CHIA LIỀN / ĐỨT
+// Mỗi mắt lưới xét ở TRUNG ĐIỂM rồi gom các mắt liên tiếp cùng trạng thái
+// (cùng lối với `_doan-tu-dong` của da-dien.typ).
+// =====================================================================
+#let _duong-tu-dong(
+ ctx, p, e, ds-khoi, pts,
+ mau: black, day: 1.1pt, hien-khuat: true,
+ mau-khuat: auto, day-khuat: auto, bo: -1,
+) = {
+ let m = pts.len()
+ if m < 2 { return }
+ let trang = range(m - 1).map(i => {
+ let M = v3-nhan(0.5, v3-cong(pts.at(i), pts.at(i + 1)))
+ _bi-che(M, e, ds-khoi, bo: bo)
+ })
+ let dau = 0
+ let i = 1
+ while i <= m - 1 {
+ let het = i == m - 1
+ if het or trang.at(i) != trang.at(dau) {
+ let cuoi = if het { m - 1 } else { i }
+ let che = trang.at(dau)
+ if not che or hien-khuat {
+ duong-cong(
+ ctx, range(dau, cuoi + 1).map(j => p(pts.at(j))),
+ mau: if not che or mau-khuat == auto { mau } else { mau-khuat },
+ day: if not che or day-khuat == auto { day } else { day-khuat },
+ dut: che,
+ )
+ }
+ dau = i
+ }
+ i = i + 1
+ }
+}
+
+// =====================================================================
+// HÌNH HỌC CỦA TỪNG KHỐI — viết theo KHUNG RIÊNG (u, v, w)
+// =====================================================================
+// Điểm trên đường tròn bán kính R quanh trục, cách đáy `h` dọc trục.
+#let _diem-vanh(k, R, t, h) = v3-cong(
+ v3-cong(k.tam, v3-nhan(h, k.w)),
+ v3-cong(v3-nhan(R * calc.cos(t), k.u), v3-nhan(R * calc.sin(t), k.v)),
+)
+#let _cung-vanh(k, R, h, tu, den, n: 48) = range(n + 1).map(i => {
+ _diem-vanh(k, R, tu + (den - tu) * (i / n), h)
+})
+
+// Hiệu hai điểm 2D · tích có hướng 2D.
+#let _tru2(a, b) = (a.at(0) - b.at(0), a.at(1) - b.at(1))
+#let _cheo2(a, b) = a.at(0) * b.at(1) - a.at(1) * b.at(0)
+
+// Góc ứng với điểm GẦN người nhìn nhất trên vành: độ sâu tỉ lệ với
+// cos t·(u·e) + sin t·(v·e), lớn nhất tại atan2(u·e, v·e).
+#let _goc-gan(k, e) = calc.atan2(v3-vo-huong(k.u, e), v3-vo-huong(k.v, e))
+
+// Hai góc ứng với ĐƯỜNG SINH BIÊN của khối.
+// • trụ : pháp tuyến mặt bên vuông góc tia nhìn ⇔ _goc-gan ± 90°.
+// • nón : điều kiện hình chiếu đường sinh TIẾP XÚC elip đáy —
+// (W×U)·sin t − (W×V)·cos t = −r·(U×V)/cao,
+// với U, V, W là ảnh của khung riêng (u, v, w) qua phép chiếu.
+// (Kiểm chứng với trục đứng + chieu-oxyz: ra đúng
+// cos t + k·cos(goc)·sin t = −r·k·sin(goc)/cao.)
+#let _goc-bien(k, p, e) = {
+ let g0 = _goc-gan(k, e)
+ if k.loai != "non" { return (g0 - 90deg, g0 + 90deg) }
+ let o = p((0, 0, 0))
+ let U = _tru2(p(k.u), o)
+ let V = _tru2(p(k.v), o)
+ let W = _tru2(p(k.w), o)
+ let a = _cheo2(W, U)
+ let b = -_cheo2(W, V)
+ let bien-do = calc.sqrt(a * a + b * b)
+ if bien-do < 1e-12 { return (g0 - 90deg, g0 + 90deg) }
+ let ve-phai = -k.r * _cheo2(U, V) / (k.cao * bien-do)
+ let dt = calc.acos(calc.max(-1.0, calc.min(1.0, ve-phai)))
+ let ph = calc.atan2(b, a)
+ (ph - dt, ph + dt)
+}
+
+// Các đường phải vẽ của một khối: mảng (pts, tu-che). `tu-che = false`
+// nghĩa là đường BIÊN — không để chính khối đó che.
+#let _duong-khoi(k, p, e, n: 48) = {
+ let (t1, t2) = _goc-bien(k, p, e)
+ // ÉP KIỂU int: calc.round và phép chia của Typst trả FLOAT, mà `range`
+ // chỉ nhận số nguyên ("expected integer, found float").
+ let ns = calc.max(2, int(n / 2))
+ let ds = ((_cung-vanh(k, k.r, 0, 0deg, 360deg, n: n), true),)
+ if k.loai == "non" {
+ let S = dinh-non(k)
+ for t in (t1, t2) {
+ let B = _diem-vanh(k, k.r, t, 0)
+ ds.push((
+ range(ns + 1).map(i => v3-cong(B, v3-nhan(i / ns, v3-tru(S, B)))),
+ false,
+ ))
+ }
+ } else {
+ ds.push((_cung-vanh(k, k.r, k.cao, 0deg, 360deg, n: n), true))
+ for t in (t1, t2) {
+ let A = _diem-vanh(k, k.r, t, 0)
+ ds.push((
+ range(ns + 1).map(i => v3-cong(A, v3-nhan(k.cao * i / ns, k.w))),
+ false,
+ ))
+ }
+ }
+ ds
+}
+
+// Đường bao ngoài (dùng để tô màu): nón = cung đáy thấy được + đỉnh;
+// trụ = cung đáy phía trước + cung nắp phía sau.
+#let _bao-khoi(k, p, e, n: 48) = {
+ let (t1, t2) = _goc-bien(k, p, e)
+ if k.loai == "non" {
+ _cung-vanh(k, k.r, 0, t1, t2, n: n) + (dinh-non(k),)
+ } else {
+ // BẮT BUỘC bọc ngoặc: `+` ở ĐẦU dòng nối bị Typst đọc thành dấu dương
+ // một ngôi ("cannot apply unary '+' to array").
+ (
+ _cung-vanh(k, k.r, 0, t1, t2, n: n)
+ + _cung-vanh(k, k.r, k.cao, t2, t1 + 360deg, n: n)
+ )
+ }
+}
+
+// Các điểm bao của khối (để dựng cửa sổ khung hình). Lấy 12 hướng quanh mỗi
+// vành chứ không phải 4 — hình chiếu của đường tròn là elip XIÊN nên bốn
+// điểm theo trục toạ độ hụt mất phần chìa ra hai bên.
+#let _chot-khoi(k, m: 12) = {
+ let ds = ()
+ for h in (0, k.cao) {
+ let bk = if k.loai == "non" and h == k.cao { 0.0 } else { k.r }
+ for i in range(m) { ds.push(_diem-vanh(k, bk, 360deg * (i / m), h)) }
+ }
+ ds
+}
+#let _chot-tat-ca(ds) = {
+ let kq = ()
+ for k in ds { kq = kq + _chot-khoi(k) }
+ kq
+}
+
+// =====================================================================
+// HỆ TRỤC Oxyz VẼ BẰNG CHÍNH CAMERA CỦA KHỐI
+// Lý do phải có: `oxyz` của hinh-khong-gian.typ tự dựng phép chiếu XIÊN
+// riêng của nó. Trong phép chiếu xiên, đường tròn nằm ngang chiếu ra elip
+// NGHIÊNG trong khi Oz vẫn dựng đứng — nhìn vô lý. Vẽ trục bằng chính
+// camera của khối thì cả khung hình chung MỘT góc nhìn. Với
+// `chieu-truc-giao(cao:)` thì elip đáy của khối ĐỨNG có trục lớn NẰM NGANG
+// và trục khối THẲNG ĐỨNG — đúng như nhìn vật thật.
+// NHẬN ctx ⇒ PHẢI kê vào `ve-voi` và khối `_voi-ctx`.
+// =====================================================================
+#let ve-truc-3d(
+ ctx, cam,
+ x: 3, y: 4, z: 4,
+ am: 0.4, // kéo dài thêm về phía âm
+ dm: 0.55, // chừa chỗ cho mũi tên + nhãn ở đầu dương
+ ten: ($x$, $y$, $z$), ten-goc: $O$,
+ huong-ten: ("below-left", "below-right", "left"),
+ huong-goc: "below-right",
+ mau: black, day: 0.9pt, cach: 5pt,
+) = {
+ let p = _cam(cam).p
+ let bo3 = (
+ ((1, 0, 0), x, ten.at(0), huong-ten.at(0)),
+ ((0, 1, 0), y, ten.at(1), huong-ten.at(1)),
+ ((0, 0, 1), z, ten.at(2), huong-ten.at(2)),
+ )
+ for (u, L, t, hg) in bo3 {
+ let B = v3-nhan(L + am + dm, u)
+ mui-ten(ctx, p(v3-nhan(-am, u)), p(B), mau: mau, day: day)
+ if t != none { nhan(ctx, p(B), t, huong: hg, cach: cach) }
+ }
+ if ten-goc != none {
+ nhan(ctx, p((0, 0, 0)), ten-goc, huong: huong-goc, cach: 4pt)
+ }
+}
+
+// Hai đầu mút ba trục (để cửa sổ khung hình bao được cả hệ trục).
+#let _chot-truc(tr) = {
+ let am = tr.at("am", default: 0.4)
+ let dm = tr.at("dm", default: 0.55)
+ let kq = ()
+ for (khoa, u) in (("x", (1, 0, 0)), ("y", (0, 1, 0)), ("z", (0, 0, 1))) {
+ let L = tr.at(khoa, default: if khoa == "x" { 3 } else { 4 })
+ kq.push(v3-nhan(-am, u))
+ kq.push(v3-nhan(L + am + dm, u))
+ }
+ kq
+}
+
+// =====================================================================
+// VE-MAT-CONG — NHẬN ctx ⇒ PHẢI kê vào `ve-voi` và khối `_voi-ctx`
+// =====================================================================
+#let ve-mat-cong(
+ ctx,
+ ..khoi,
+ cam: auto,
+ mau: black, day: 1.1pt, to: none,
+ hien-khuat: true, mau-khuat: auto, day-khuat: auto,
+ n: 48,
+ duong: (),
+ truc: none, // dict tuỳ chọn của `ve-truc-3d`, vd truc: (x: 3, y: 5, z: 5)
+ truoc: none, // vẽ TRƯỚC khối · them: vẽ SAU khối
+ them: none,
+) = {
+ let ds0 = khoi.pos()
+ if ds0.len() == 0 { return }
+ // gắn `lui` cho từng khối: phải LỚN HƠN độ võng dây cung của lưới n cạnh,
+ // xem giải thích ở `_trong-khoi`. Hệ số 1.6 là biên an toàn.
+ let ds = ds0.map(k => k + (
+ lui: calc.max(1e-4, 1.6 * k.r * (1 - calc.cos(180deg / n))),
+ ))
+ let cm = _cam-mc(cam)
+ let p = cm.p
+ let e = cm.nhin
+ // 0) hệ trục + lớp vẽ nền — nằm DƯỚI khối
+ if truc != none { ve-truc-3d(ctx, cm, ..truc) }
+ if truoc != none { truoc(ctx, p) }
+ // 1) tô màu trước, để nét không bị màu đè lên
+ for k in ds {
+ let mt = if k.to == auto { to } else { k.to }
+ if mt != none {
+ da-giac-pt(_bao-khoi(k, p, e, n: n).map(P => toa-pt(ctx, p(P))), to: mt)
+ }
+ }
+ // 2) rồi mới kẻ nét, tự chia liền / đứt
+ for j in range(ds.len()) {
+ let k = ds.at(j)
+ let mk = if k.mau == auto { mau } else { k.mau }
+ for (pts, tu-che) in _duong-khoi(k, p, e, n: n) {
+ _duong-tu-dong(
+ ctx, p, e, ds, pts,
+ mau: mk, day: day, hien-khuat: hien-khuat,
+ mau-khuat: mau-khuat, day-khuat: day-khuat,
+ bo: if tu-che { -1 } else { j },
+ )
+ }
+ }
+ // 3) các đường vẽ thêm (thiết diện, đường tròn phụ…) — cũng tự chia liền/đứt.
+ // Mỗi mục là một DICT: (pts: mảng điểm 3D, mau: …, day: …, hien-khuat: …)
+ for m in duong {
+ _duong-tu-dong(
+ ctx, p, e, ds, m.pts,
+ mau: m.at("mau", default: mau),
+ day: m.at("day", default: day),
+ hien-khuat: m.at("hien-khuat", default: hien-khuat),
+ mau-khuat: mau-khuat, day-khuat: day-khuat,
+ )
+ }
+ if them != none { them(ctx, p) }
+}
+
+// =====================================================================
+// MAT-CONG — TỰ tạo khung hình (KHÔNG kê vào ve-voi)
+// Mọi tuỳ chọn khác đều chảy thẳng xuống `ve-mat-cong` qua sink `..khoi`.
+// =====================================================================
+#let mat-cong(..khoi, cam: auto, w: 7cm, le: 0.75, co-chu: 10pt) = {
+ let cm = _cam-mc(cam)
+ let tr = khoi.named().at("truc", default: none)
+ let chot = _chot-tat-ca(khoi.pos()) + (if tr == none { () } else { _chot-truc(tr) })
+ let pts = chot.map(cm.p)
+ let xs = pts.map(q => q.at(0))
+ let ys = pts.map(q => q.at(1))
+ hinh(
+ w: w,
+ xmin: calc.min(..xs) - le, xmax: calc.max(..xs) + le,
+ ymin: calc.min(..ys) - le, ymax: calc.max(..ys) + le,
+ co-chu: co-chu,
+ ctx => ve-mat-cong(ctx, ..khoi, cam: cm),
+ )
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/oxyz-toan.typ b/packages/preview/conic-toan/0.3.4/lib/oxyz-toan.typ
new file mode 100644
index 0000000000..c4fec33ca7
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/oxyz-toan.typ
@@ -0,0 +1,544 @@
+// =====================================================================
+// oxyz-toan.typ — TÍNH TOÁN trong không gian toạ độ Oxyz
+// Toàn bộ hàm ở file này TRẢ VỀ GIÁ TRỊ (số / điểm / dict) hoặc NỘI DUNG
+// toán đã định dạng ⇒ KHÔNG kê vào `ve-voi` / `_voi-ctx` của baigiang.typ.
+//
+// Quy ước kiểu dữ liệu
+// điểm / vectơ : (x, y, z)
+// mặt phẳng : (a: , b: , c: , d: ) ⇔ ax + by + cz + d = 0
+// (cũng nhận dạng (n: , d: ) của da-dien.typ)
+// đường thẳng : (P: điểm đi qua, u: vectơ chỉ phương)
+// mặt cầu : (I: tâm, R: bán kính)
+//
+// Nhóm hàm `hien-*` trả về NỘI DUNG toán CHÍNH XÁC (căn thức, phân số),
+// nhóm còn lại trả số thực để tính tiếp / vẽ hình.
+// =====================================================================
+#import "da-dien.typ": *
+#import "do-thi.typ": so-toan, so-dep, so-can-thuc
+
+#let _e0 = 1e-9
+
+#let _gan-int(x, eps: 1e-9) = calc.abs(x - calc.round(x)) < eps * calc.max(1, calc.abs(x))
+
+#let _gcd2(a, b) = {
+ let (a, b) = (calc.abs(int(a)), calc.abs(int(b)))
+ while b != 0 { let r = calc.rem(a, b); a = b; b = r }
+ a
+}
+
+// Nhân dãy hệ số với số hữu tỉ nhỏ nhất để thành NGUYÊN rồi chia ước chung;
+// dấu chuẩn hoá: hệ số khác 0 đầu tiên > 0. Không nguyên hoá được -> none.
+#let _rut-gon(ds) = {
+ let ok = none
+ for q in range(1, 61) {
+ let t = ds.map(v => v * q)
+ if t.all(v => _gan-int(v, eps: 1e-7)) {
+ ok = t.map(v => int(calc.round(v)))
+ break
+ }
+ }
+ if ok == none { return none }
+ let g = 0
+ for v in ok { g = _gcd2(g, v) }
+ if g == 0 { return ok }
+ let r = ok.map(v => int(v / g))
+ let am = false
+ for v in r {
+ if v != 0 { am = v < 0; break }
+ }
+ if am { r = r.map(v => -v) }
+ r
+}
+
+// Số -> CHUỖI toán (nguyên, phân số p/q rút gọn, cùng lắm là thập phân 3 chữ số).
+// Lượt mẫu LỚN (tới 5000, khớp chặt 1e-11) để tâm ngoại tiếp/trực tâm — vốn là
+// phân số mẫu vài trăm — vẫn hiện CHÍNH XÁC thay vì số thập phân.
+#let _ms(v) = {
+ if _gan-int(v) { return str(int(calc.round(v))) }
+ for q in range(2, 101) {
+ let p = v * q
+ if _gan-int(p) {
+ let p = int(calc.round(p))
+ if _gcd2(p, q) == 1 { return str(p) + "/" + str(q) }
+ }
+ }
+ for q in range(101, 5001) {
+ let p = v * q
+ if _gan-int(p, eps: 1e-11) {
+ let p = int(calc.round(p))
+ if _gcd2(p, q) == 1 { return str(p) + "/" + str(q) }
+ }
+ }
+ let r = calc.round(v, digits: 3)
+ if calc.fract(r) == 0 { str(int(r)) } else { str(r) }
+}
+
+// Có phải chuỗi số NGUYÊN (để biết cần bọc ngoặc hay không).
+#let _la-nguyen(v) = _gan-int(v)
+
+// Vế trái "2x - 3y + z - 5" từ hệ số + tên biến ("" = hạng tử tự do).
+#let _ve-trai(hs, bien) = {
+ let s = ""
+ for (v, b) in hs.zip(bien) {
+ if calc.abs(v) < 1e-12 { continue }
+ let am = v < 0
+ let a = calc.abs(v)
+ let t = if b == "" {
+ _ms(a)
+ } else if _la-nguyen(a) and calc.round(a) == 1 {
+ b
+ } else if _la-nguyen(a) {
+ _ms(a) + b
+ } else {
+ "(" + _ms(a) + ")" + b
+ }
+ if s == "" {
+ s = if am { "-" + t } else { t }
+ } else {
+ s = s + (if am { " - " } else { " + " }) + t
+ }
+ }
+ if s == "" { s = "0" }
+ s
+}
+
+// Bọc ngoặc khi số âm hoặc là phân số (dùng làm mẫu số, hệ số đứng riêng).
+#let _ms-ngoac(v) = {
+ let s = _ms(v)
+ if v < 0 or not _la-nguyen(v) { "(" + s + ")" } else { s }
+}
+
+// =====================================================================
+// 1. VECTƠ
+// (các phép cơ bản v3-cong, v3-tru, v3-nhan, v3-chuan… có sẵn ở da-dien.typ)
+// =====================================================================
+
+// Toạ độ vectơ AB = B - A. Gọi 1 đối số thì trả về chính vectơ đó.
+#let vecto-3d(..a) = {
+ let p = a.pos()
+ if p.len() == 1 { p.at(0) } else { v3-tru(p.at(1), p.at(0)) }
+}
+
+#let tich-vo-huong(u, v) = v3-vo-huong(u, v)
+#let tich-co-huong(u, v) = v3-co-huong(u, v)
+// Tích hỗn tạp [u, v, w] = (u × v) · w (định thức cấp ba).
+#let tich-hon-tap(u, v, w) = v3-vo-huong(v3-co-huong(u, v), w)
+
+#let do-dai-vecto(u) = v3-dai(u)
+
+// cos của góc giữa hai vectơ (giá trị thực, đã chặn trong [-1; 1]).
+#let cos-goc-vecto(u, v) = {
+ let m = v3-dai(u) * v3-dai(v)
+ if m < _e0 { return 0.0 }
+ calc.max(-1.0, calc.min(1.0, v3-vo-huong(u, v) / m))
+}
+// Góc giữa hai vectơ (0° -> 180°), trả về kiểu angle.
+#let goc-vecto(u, v) = calc.acos(cos-goc-vecto(u, v))
+
+#let cung-phuong(u, v) = v3-dai(v3-co-huong(u, v)) < 1e-7 * calc.max(1, v3-dai(u) * v3-dai(v))
+#let vuong-goc(u, v) = calc.abs(v3-vo-huong(u, v)) < 1e-7 * calc.max(1, v3-dai(u) * v3-dai(v))
+#let dong-phang(u, v, w) = calc.abs(tich-hon-tap(u, v, w)) < 1e-7
+
+// =====================================================================
+// 2. ĐIỂM — khoảng cách, điểm đặc biệt của tam giác
+// (trung-diem-3d, chia-3d, tam-3d đã có ở da-dien.typ)
+// =====================================================================
+#let khoang-cach-3d(A, B) = v3-dai(v3-tru(B, A))
+#let do-dai-doan-3d(A, B) = khoang-cach-3d(A, B)
+
+#let trong-tam-3d(..P) = {
+ let ds = P.pos()
+ v3-nhan(1 / ds.len(), ds.fold((0.0, 0.0, 0.0), v3-cong))
+}
+
+// Tâm đường tròn NGOẠI tiếp tam giác ABC (toạ độ tâm tỉ cự).
+#let tam-ngoai-tiep-3d(A, B, C) = {
+ let a2 = v3-vo-huong(v3-tru(C, B), v3-tru(C, B))
+ let b2 = v3-vo-huong(v3-tru(C, A), v3-tru(C, A))
+ let c2 = v3-vo-huong(v3-tru(B, A), v3-tru(B, A))
+ let al = a2 * (b2 + c2 - a2)
+ let be = b2 * (c2 + a2 - b2)
+ let ga = c2 * (a2 + b2 - c2)
+ let s = al + be + ga
+ if calc.abs(s) < _e0 { return trong-tam-3d(A, B, C) }
+ v3-nhan(1 / s, v3-cong(v3-cong(v3-nhan(al, A), v3-nhan(be, B)), v3-nhan(ga, C)))
+}
+
+// Trực tâm — hệ thức Euler: H = A + B + C - 2·O.
+#let truc-tam-3d(A, B, C) = v3-tru(
+ v3-cong(v3-cong(A, B), C),
+ v3-nhan(2, tam-ngoai-tiep-3d(A, B, C)),
+)
+
+// Tâm đường tròn NỘI tiếp: I = (a·A + b·B + c·C)/(a + b + c).
+#let tam-noi-tiep-3d(A, B, C) = {
+ let a = khoang-cach-3d(B, C)
+ let b = khoang-cach-3d(C, A)
+ let c = khoang-cach-3d(A, B)
+ let s = a + b + c
+ if s < _e0 { return A }
+ v3-nhan(1 / s, v3-cong(v3-cong(v3-nhan(a, A), v3-nhan(b, B)), v3-nhan(c, C)))
+}
+
+// Tâm đường tròn BÀNG tiếp trong góc A.
+#let tam-bang-tiep-3d(A, B, C) = {
+ let a = khoang-cach-3d(B, C)
+ let b = khoang-cach-3d(C, A)
+ let c = khoang-cach-3d(A, B)
+ let s = -a + b + c
+ if calc.abs(s) < _e0 { return A }
+ v3-nhan(1 / s, v3-cong(v3-cong(v3-nhan(-a, A), v3-nhan(b, B)), v3-nhan(c, C)))
+}
+
+// =====================================================================
+// 3. DIỆN TÍCH — THỂ TÍCH
+// =====================================================================
+#let dien-tich-tam-giac-3d(A, B, C) = 0.5 * v3-dai(v3-co-huong(v3-tru(B, A), v3-tru(C, A)))
+#let dien-tich-hbh-3d(A, B, C) = v3-dai(v3-co-huong(v3-tru(B, A), v3-tru(C, A)))
+#let the-tich-tu-dien(A, B, C, D) = calc.abs(
+ tich-hon-tap(v3-tru(B, A), v3-tru(C, A), v3-tru(D, A)),
+) / 6
+#let the-tich-hinh-hop(A, B, C, D) = calc.abs(
+ tich-hon-tap(v3-tru(B, A), v3-tru(C, A), v3-tru(D, A)),
+)
+#let bon-diem-dong-phang(A, B, C, D) = dong-phang(
+ v3-tru(B, A), v3-tru(C, A), v3-tru(D, A),
+)
+#let ban-kinh-ngoai-tiep-3d(A, B, C) = {
+ let s = dien-tich-tam-giac-3d(A, B, C)
+ if s < _e0 { return 0.0 }
+ (khoang-cach-3d(B, C) * khoang-cach-3d(C, A) * khoang-cach-3d(A, B)) / (4 * s)
+}
+#let ban-kinh-noi-tiep-3d(A, B, C) = {
+ let p = (khoang-cach-3d(B, C) + khoang-cach-3d(C, A) + khoang-cach-3d(A, B)) / 2
+ if p < _e0 { return 0.0 }
+ dien-tich-tam-giac-3d(A, B, C) / p
+}
+
+// =====================================================================
+// 4. MẶT PHẲNG ax + by + cz + d = 0
+// =====================================================================
+
+// Nhận cả dạng (a,b,c,d) lẫn dạng (n: đơn vị, d:) của da-dien.typ.
+#let _hs-mp(m) = {
+ if "a" in m { (m.a, m.b, m.c, m.d) } else { (m.n.at(0), m.n.at(1), m.n.at(2), -m.d) }
+}
+#let phap-tuyen-mp(m) = {
+ let h = _hs-mp(m)
+ (h.at(0), h.at(1), h.at(2))
+}
+// Đổi sang dạng (n: , d: ) để VẼ bằng mat-phang-bh / hinh-chieu-mp của da-dien.
+#let mp-de-ve(m) = {
+ let h = _hs-mp(m)
+ let n = v3-chuan((h.at(0), h.at(1), h.at(2)))
+ let l = v3-dai((h.at(0), h.at(1), h.at(2)))
+ (n: n, d: if l < _e0 { 0.0 } else { -h.at(3) / l })
+}
+
+#let mat-phang-qua-phap(A, n) = {
+ let h = _rut-gon((n.at(0), n.at(1), n.at(2), -v3-vo-huong(n, A)))
+ let h = if h == none { (n.at(0), n.at(1), n.at(2), -v3-vo-huong(n, A)) } else { h }
+ (a: h.at(0), b: h.at(1), c: h.at(2), d: h.at(3))
+}
+#let mat-phang-qua-3-diem(A, B, C) = mat-phang-qua-phap(
+ A, v3-co-huong(v3-tru(B, A), v3-tru(C, A)),
+)
+// Mặt phẳng đoạn chắn: x/p + y/q + z/r = 1.
+#let mat-phang-doan-chan(p, q, r) = mat-phang-qua-3-diem((p, 0, 0), (0, q, 0), (0, 0, r))
+#let mat-phang-song-song(m, A) = mat-phang-qua-phap(A, phap-tuyen-mp(m))
+#let mat-phang-trung-truc(A, B) = mat-phang-qua-phap(trung-diem-3d(A, B), v3-tru(B, A))
+// Mặt phẳng qua A và vuông góc đường thẳng d.
+#let mat-phang-vuong-goc-duong(A, d) = mat-phang-qua-phap(A, d.u)
+// Mặt phẳng qua A, chứa hai phương u, v.
+#let mat-phang-qua-2-phuong(A, u, v) = mat-phang-qua-phap(A, v3-co-huong(u, v))
+
+// Thay toạ độ điểm vào vế trái: ax + by + cz + d.
+#let the-vao-mp(A, m) = {
+ let h = _hs-mp(m)
+ h.at(0) * A.at(0) + h.at(1) * A.at(1) + h.at(2) * A.at(2) + h.at(3)
+}
+#let khoang-cach-diem-mp(A, m) = {
+ let n = phap-tuyen-mp(m)
+ let l = v3-dai(n)
+ if l < _e0 { return 0.0 }
+ calc.abs(the-vao-mp(A, m)) / l
+}
+#let hinh-chieu-len-mp(A, m) = {
+ let n = phap-tuyen-mp(m)
+ let l2 = v3-vo-huong(n, n)
+ if l2 < _e0 { return A }
+ v3-tru(A, v3-nhan(the-vao-mp(A, m) / l2, n))
+}
+#let doi-xung-qua-mp(A, m) = {
+ let n = phap-tuyen-mp(m)
+ let l2 = v3-vo-huong(n, n)
+ if l2 < _e0 { return A }
+ v3-tru(A, v3-nhan(2 * the-vao-mp(A, m) / l2, n))
+}
+// Khung tứ giác biểu diễn mặt phẳng để VẼ bằng mat-phang-bh của da-dien.typ:
+// let (T, u, v) = khung-mp(P, tam: A, r: 2.2)
+// mat-phang-bh(ctx, t3, T, u, v)
+#let khung-mp(m, tam: auto, r: 2) = {
+ let n = v3-chuan(phap-tuyen-mp(m))
+ let T = hinh-chieu-len-mp(if tam == auto { (0, 0, 0) } else { tam }, m)
+ let t = if calc.abs(n.at(2)) < 0.9 { (0, 0, 1) } else { (1, 0, 0) }
+ let u = v3-nhan(r, v3-chuan(v3-co-huong(n, t)))
+ let v = v3-nhan(r, v3-chuan(v3-co-huong(n, u)))
+ (T, u, v)
+}
+
+#let goc-2-mp(m1, m2) = calc.acos(
+ calc.min(1.0, calc.abs(cos-goc-vecto(phap-tuyen-mp(m1), phap-tuyen-mp(m2)))),
+)
+#let vi-tri-2-mp(m1, m2) = {
+ let (n1, n2) = (phap-tuyen-mp(m1), phap-tuyen-mp(m2))
+ if not cung-phuong(n1, n2) { return "cắt nhau" }
+ let (h1, h2) = (_hs-mp(m1), _hs-mp(m2))
+ let k = 0.0
+ for i in range(3) {
+ if calc.abs(h2.at(i)) > _e0 { k = h1.at(i) / h2.at(i) }
+ }
+ if calc.abs(h1.at(3) - k * h2.at(3)) < 1e-7 { "trùng nhau" } else { "song song" }
+}
+
+// Phương trình mặt phẳng: nội dung $2x - 3y + z - 5 = 0$
+#let pt-mat-phang(m, an: ("x", "y", "z")) = {
+ let h = _hs-mp(m)
+ let r = _rut-gon(h)
+ let h = if r == none { h } else { r }
+ eval(_ve-trai(h, (an.at(0), an.at(1), an.at(2), "")) + " = 0", mode: "math")
+}
+
+// =====================================================================
+// 5. ĐƯỜNG THẲNG (P: điểm, u: vectơ chỉ phương)
+// =====================================================================
+// Vectơ chỉ phương "đẹp": nhân lên thành nguyên, chia ước chung, thành phần
+// khác 0 đầu tiên dương. Không nguyên hoá được thì giữ nguyên.
+#let vtcp-dep(u) = {
+ let r = _rut-gon(u)
+ if r == none { u } else { r }
+}
+#let duong-thang-qua-vtcp(A, u) = (P: A, u: u)
+#let duong-thang-qua-2-diem(A, B) = (P: A, u: v3-tru(B, A))
+#let duong-thang-vuong-goc-mp(A, m) = (P: A, u: phap-tuyen-mp(m))
+
+#let khoang-cach-diem-duong(A, d) = {
+ let l = v3-dai(d.u)
+ if l < _e0 { return khoang-cach-3d(A, d.P) }
+ v3-dai(v3-co-huong(v3-tru(A, d.P), d.u)) / l
+}
+#let hinh-chieu-len-duong(A, d) = {
+ let l2 = v3-vo-huong(d.u, d.u)
+ if l2 < _e0 { return d.P }
+ v3-cong(d.P, v3-nhan(v3-vo-huong(v3-tru(A, d.P), d.u) / l2, d.u))
+}
+#let doi-xung-qua-duong(A, d) = v3-tru(v3-nhan(2, hinh-chieu-len-duong(A, d)), A)
+#let goc-2-duong(d1, d2) = calc.acos(calc.min(1.0, calc.abs(cos-goc-vecto(d1.u, d2.u))))
+#let goc-duong-mp(d, m) = calc.asin(
+ calc.min(1.0, calc.abs(cos-goc-vecto(d.u, phap-tuyen-mp(m)))),
+)
+
+#let vi-tri-2-duong(d1, d2) = {
+ let w = v3-tru(d2.P, d1.P)
+ if cung-phuong(d1.u, d2.u) {
+ if v3-dai(v3-co-huong(w, d1.u)) < 1e-7 { "trùng nhau" } else { "song song" }
+ } else if calc.abs(tich-hon-tap(d1.u, d2.u, w)) < 1e-7 { "cắt nhau" } else { "chéo nhau" }
+}
+#let khoang-cach-2-duong(d1, d2) = {
+ let n = v3-co-huong(d1.u, d2.u)
+ if v3-dai(n) < 1e-7 { return khoang-cach-diem-duong(d2.P, d1) }
+ calc.abs(v3-vo-huong(n, v3-tru(d2.P, d1.P))) / v3-dai(n)
+}
+// Giao điểm đường thẳng với mặt phẳng — song song / nằm trong thì trả none.
+#let giao-duong-mp(d, m) = {
+ let n = phap-tuyen-mp(m)
+ let t0 = v3-vo-huong(n, d.u)
+ if calc.abs(t0) < 1e-9 { return none }
+ v3-cong(d.P, v3-nhan(-the-vao-mp(d.P, m) / t0, d.u))
+}
+// Giao điểm hai đường thẳng (chéo nhau / song song -> none).
+#let giao-2-duong(d1, d2) = {
+ if vi-tri-2-duong(d1, d2) != "cắt nhau" { return none }
+ let w = v3-tru(d2.P, d1.P)
+ let n = v3-co-huong(d1.u, d2.u)
+ let t = v3-vo-huong(v3-co-huong(w, d2.u), n) / v3-vo-huong(n, n)
+ v3-cong(d1.P, v3-nhan(t, d1.u))
+}
+// Giao tuyến hai mặt phẳng (song song / trùng -> none).
+#let giao-2-mp(m1, m2) = {
+ let (n1, n2) = (phap-tuyen-mp(m1), phap-tuyen-mp(m2))
+ let u = v3-co-huong(n1, n2)
+ if v3-dai(u) < 1e-7 { return none }
+ let (h1, h2) = (_hs-mp(m1), _hs-mp(m2))
+ let (k1, k2) = (-h1.at(3), -h2.at(3))
+ let (a11, a12) = (v3-vo-huong(n1, n1), v3-vo-huong(n1, n2))
+ let a22 = v3-vo-huong(n2, n2)
+ let de = a11 * a22 - a12 * a12
+ let al = (k1 * a22 - k2 * a12) / de
+ let be = (k2 * a11 - k1 * a12) / de
+ (P: v3-cong(v3-nhan(al, n1), v3-nhan(be, n2)), u: vtcp-dep(u))
+}
+
+// Phương trình THAM SỐ: cases(x = ..., y = ..., z = ...)
+#let pt-tham-so(d, t: "t", an: ("x", "y", "z")) = {
+ let dong = range(3).map(i => (
+ an.at(i) + " = " + _ve-trai((d.P.at(i), d.u.at(i)), ("", t))
+ ))
+ eval("cases(" + dong.join(", ") + ")", mode: "math")
+}
+// Phương trình CHÍNH TẮC (thành phần chỉ phương bằng 0 thì tách ra sau dấu phẩy).
+#let pt-chinh-tac(d, an: ("x", "y", "z")) = {
+ let ph = ()
+ let le = ()
+ for i in range(3) {
+ if calc.abs(d.u.at(i)) < _e0 {
+ le.push(an.at(i) + " = " + _ms(d.P.at(i)))
+ } else {
+ ph.push(
+ "(" + _ve-trai((1, -d.P.at(i)), (an.at(i), "")) + ")/" + _ms-ngoac(d.u.at(i)),
+ )
+ }
+ }
+ let s = if ph.len() >= 2 { (ph.join(" = "),) + le } else { ph + le }
+ eval(s.join(", "), mode: "math")
+}
+
+// =====================================================================
+// 6. MẶT CẦU (I: tâm, R: bán kính)
+// =====================================================================
+#let mat-cau(I, R) = (I: I, R: calc.abs(R))
+#let mat-cau-duong-kinh(A, B) = (I: trung-diem-3d(A, B), R: khoang-cach-3d(A, B) / 2)
+#let mat-cau-tam-tiep-xuc-mp(I, m) = (I: I, R: khoang-cach-diem-mp(I, m))
+// Mặt cầu qua 4 điểm không đồng phẳng (đồng phẳng -> none).
+#let mat-cau-qua-4-diem(A, B, C, D) = {
+ let q = P => v3-vo-huong(P, P)
+ let ha = (v3-tru(B, A), v3-tru(C, A), v3-tru(D, A))
+ let ve = ((q(B) - q(A)) / 2, (q(C) - q(A)) / 2, (q(D) - q(A)) / 2)
+ let de = tich-hon-tap(ha.at(0), ha.at(1), ha.at(2))
+ if calc.abs(de) < 1e-9 { return none }
+ let _cot-j(j) = {
+ let m = ha.map(r => r)
+ let k = range(3).map(i => {
+ let r = m.at(i)
+ range(3).map(t => if t == j { ve.at(i) } else { r.at(t) })
+ })
+ tich-hon-tap(k.at(0), k.at(1), k.at(2))
+ }
+ let I = (_cot-j(0) / de, _cot-j(1) / de, _cot-j(2) / de)
+ (I: I, R: khoang-cach-3d(I, A))
+}
+#let vi-tri-mp-mat-cau(m, S) = {
+ let k = khoang-cach-diem-mp(S.I, m)
+ if k > S.R + 1e-7 { "không cắt" } else if k > S.R - 1e-7 { "tiếp xúc" } else { "cắt nhau" }
+}
+// Đường tròn giao tuyến của mặt phẳng và mặt cầu -> (I: tâm, R: bán kính).
+#let duong-tron-giao(m, S) = {
+ let k = khoang-cach-diem-mp(S.I, m)
+ if k > S.R + 1e-9 { return none }
+ (I: hinh-chieu-len-mp(S.I, m), R: calc.sqrt(calc.max(0.0, S.R * S.R - k * k)))
+}
+
+// Phương trình mặt cầu dạng CHÍNH TẮC: (x - a)^2 + (y - b)^2 + (z - c)^2 = R^2
+#let pt-mat-cau(S, an: ("x", "y", "z")) = {
+ let ve = range(3).map(i => {
+ let t = _ve-trai((1, -S.I.at(i)), (an.at(i), ""))
+ if t.contains(" ") { "(" + t + ")^2" } else { t + "^2" }
+ })
+ let r2 = S.R * S.R
+ eval(ve.join(" + ") + " = " + _ms(r2), mode: "math")
+}
+// Phương trình mặt cầu dạng KHAI TRIỂN: x^2+y^2+z^2 - 2ax - 2by - 2cz + d = 0
+#let pt-mat-cau-khai-trien(S, an: ("x", "y", "z")) = {
+ let d0 = v3-vo-huong(S.I, S.I) - S.R * S.R
+ let dau = _ve-trai(
+ (-2 * S.I.at(0), -2 * S.I.at(1), -2 * S.I.at(2), d0),
+ (an.at(0), an.at(1), an.at(2), ""),
+ )
+ let dau = if dau == "0" { "" } else if dau.starts-with("-") {
+ " - " + dau.slice(1)
+ } else { " + " + dau }
+ eval(
+ an.at(0) + "^2 + " + an.at(1) + "^2 + " + an.at(2) + "^2" + dau + " = 0",
+ mode: "math",
+ )
+}
+
+// =====================================================================
+// 7. HIỂN THỊ — nội dung toán CHÍNH XÁC (căn thức, phân số)
+// =====================================================================
+// Số CHÍNH XÁC nếu là nguyên / phân số (mẫu tới 5000), ngược lại là thập phân
+// 3 chữ số. KHÔNG "đoán" căn thức như so-toan (dễ ra dạng đẹp mà SAI với số vô
+// tỉ); cần căn thức chính xác thì dùng hien-can / hien-do-dai / hien-dien-tich-*.
+#let hien-so(v) = eval(_ms(v), mode: "math")
+// Giá trị gần đúng (mặc định 2 chữ số thập phân).
+#let hien-gan-dung(v, chu-so: 2) = {
+ let r = calc.round(v, digits: chu-so)
+ eval(if calc.fract(r) == 0 { str(int(r)) } else { str(r) }, mode: "math")
+}
+// Căn bậc hai CHÍNH XÁC của số nguyên n (tự rút thừa số chính phương).
+#let hien-can(n) = so-can-thuc(0, 1, n, 1)
+// Tên điểm/vectơ: chuỗi "AB" -> $A B$ (math của Typst coi chuỗi nhiều chữ cái
+// là MỘT biến nên phải tách ra); truyền thẳng nội dung toán cũng được.
+#let _ten-toan(t) = if type(t) == str { eval(t.clusters().join(" "), mode: "math") } else { t }
+#let _bo-ba(u) = eval("(" + u.map(v => _ms(v)).join("; ") + ")", mode: "math")
+
+// Toạ độ điểm: A(1; 2; -3)
+#let hien-diem(A, ten: none) = {
+ let s = _bo-ba(A)
+ if ten == none { s } else { $#_ten-toan(ten)#s$ }
+}
+// Toạ độ vectơ: arrow(u) = (1; 2; -3)
+#let hien-vecto(u, ten: none) = {
+ let s = _bo-ba(u)
+ if ten == none { s } else { $arrow(#_ten-toan(ten)) = #s$ }
+}
+#let hien-goc(g, chu-so: 1) = {
+ let d = g.deg()
+ let s = if _gan-int(d, eps: 1e-6) {
+ str(int(calc.round(d)))
+ } else { so-dep(calc.round(d, digits: chu-so)) }
+ eval(s + " degree", mode: "math")
+}
+// Độ dài vectơ / khoảng cách hai điểm — dạng căn thức chính xác.
+#let hien-do-dai(u) = so-can-thuc(0, 1, v3-vo-huong(u, u), 1)
+#let hien-khoang-cach(A, B) = hien-do-dai(v3-tru(B, A))
+// cos góc giữa hai vectơ — chính xác: (u·v)/(|u||v|) = t√(N)/N
+#let hien-cos-goc(u, v) = {
+ let t = v3-vo-huong(u, v)
+ let n = v3-vo-huong(u, u) * v3-vo-huong(v, v)
+ if n < _e0 { return $0$ }
+ so-can-thuc(0, t, n, n)
+}
+#let hien-khoang-cach-diem-mp(A, m) = {
+ let h = _hs-mp(m)
+ let r = _rut-gon(h)
+ let h = if r == none { h } else { r }
+ let n = h.at(0) * h.at(0) + h.at(1) * h.at(1) + h.at(2) * h.at(2)
+ if n < _e0 { return $0$ }
+ let t = calc.abs(h.at(0) * A.at(0) + h.at(1) * A.at(1) + h.at(2) * A.at(2) + h.at(3))
+ so-can-thuc(0, t, n, n)
+}
+#let hien-khoang-cach-diem-duong(A, d) = {
+ let n2 = v3-vo-huong(d.u, d.u)
+ if n2 < _e0 { return hien-khoang-cach(A, d.P) }
+ let c = v3-co-huong(v3-tru(A, d.P), d.u)
+ so-can-thuc(0, 1, v3-vo-huong(c, c) * n2, n2)
+}
+#let hien-khoang-cach-2-duong(d1, d2) = {
+ let n = v3-co-huong(d1.u, d2.u)
+ let n2 = v3-vo-huong(n, n)
+ if n2 < 1e-12 { return hien-khoang-cach-diem-duong(d2.P, d1) }
+ let t = v3-vo-huong(n, v3-tru(d2.P, d1.P))
+ so-can-thuc(0, 1, t * t / n2, 1)
+}
+// Diện tích hình bình hành dựng trên hai vectơ = √M (chính xác).
+#let hien-dien-tich-hbh(A, B, C) = {
+ let c = v3-co-huong(v3-tru(B, A), v3-tru(C, A))
+ so-can-thuc(0, 1, v3-vo-huong(c, c), 1)
+}
+// Diện tích tam giác = √M/2 (chính xác).
+#let hien-dien-tich-tam-giac(A, B, C) = {
+ let c = v3-co-huong(v3-tru(B, A), v3-tru(C, A))
+ so-can-thuc(0, 1, v3-vo-huong(c, c), 2)
+}
+#let hien-the-tich-tu-dien(A, B, C, D) = so-toan(the-tich-tu-dien(A, B, C, D))
diff --git a/packages/preview/conic-toan/0.3.4/lib/slide.typ b/packages/preview/conic-toan/0.3.4/lib/slide.typ
new file mode 100644
index 0000000000..bb4c088dcb
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/slide.typ
@@ -0,0 +1,1005 @@
+// =====================================================================
+// slide.typ — ENGINE TRÌNH CHIẾU THUẦN TYPST (16:9)
+//
+// Cách dùng:
+// #show: bai-giang.with(
+// tieu-de: [CHƯƠNG I. HÀM SỐ], gv: "Thầy/Cô ...", ...
+// )
+// #muc[1. Định nghĩa]
+// #slide(tieu-de: [Khái niệm])[ ... nội dung ... ]
+//
+// Khung nội dung: #dinh-nghia[...], #dinh-ly[...], #vi-du[...],
+// #loi-giai[...], #chu-y[...], #ghi-nho[...], #nhan-xet[...], #luyen-tap[...]
+// Bố cục: #chia-cot(trai, phai, ti-le: (3fr, 2fr))
+// =====================================================================
+
+// Bảng màu (đọc qua state để mọi hàm dùng chung).
+#let _mau = state("bg-mau", (
+ chinh: rgb("#0f4c81"), // xanh đậm chủ đạo
+ nhan: rgb("#e67e22"), // cam nhấn
+ nen: rgb("#f7f9fc"),
+))
+#let _thong-tin = state("bg-thong-tin", (tieu-de: [], gv: none, ngan: none))
+#let _muc-ht = state("bg-muc", none)
+#let _buoc-ht = state("bg-buoc", 1) // bước hoạt hình hiện tại
+#let _dem-slide = counter("bg-slide") // số thứ tự slide (không tính bước)
+
+// ----- Hồ sơ hiển thị: "beamer" (trình chiếu) hoặc bản in A4 -----
+// "sach-dethi" : A4, ví dụ hiện lời giải, 4 loại câu ẩn đáp án.
+// "sach-loigiai": A4, mọi thứ hiện lời giải + đánh dấu đáp án.
+#let _ho-so = state("bg-ho-so", "beamer")
+#let _chuan-hs(hs) = lower(hs).replace("-", "").replace("_", "")
+#let _la-sach(hs) = _chuan-hs(hs) != "beamer" // khác beamer = bản in A4
+
+// ----- KÍCH THƯỚC THÂN SLIDE 16:9 -----
+// Rút thành hằng số để `slide()` và bộ TỰ NGẮT MÀN (che-do.typ) dùng CHUNG
+// một bộ số — đổi lề ở đây là chỗ đo cũng đổi theo, không lệch nhau.
+// `presentation-16-9` của Typst = 297mm × 167,06mm.
+#let _kho-slide = (rong: 841.89pt, cao: 473.55pt)
+#let _le-slide = (
+ tren: 73pt, // chừa thanh tiêu đề slide
+ tren-tron: 26pt, // slide không có tiêu đề
+ duoi: 34pt, // chừa thanh điều hướng
+ ngang: 28pt,
+ du: 8pt, // hụt an toàn khi đo (đo bao giờ cũng có sai số)
+)
+// Vùng chữ thật sự của một slide — dùng để biết lời giải có tràn trang không.
+#let _vung-than(co-tieu-de: true) = (
+ rong: _kho-slide.rong - 2 * _le-slide.ngang,
+ cao: _kho-slide.cao - _le-slide.duoi - _le-slide.du
+ - (if co-tieu-de { _le-slide.tren } else { _le-slide.tren-tron }),
+)
+
+// ----- GIÃN DÒNG (khoảng cách giữa các dòng) -----
+// `nen` : leading NỀN của hồ sơ đang dùng (bai-giang/de-toan ghi vào);
+// `he-so` : HỆ SỐ NHÂN do người dùng đặt — 1.0 = mốc mặc định,
+// 1.3 = giãn thêm 30%, 0.9 = thu lại 10%.
+// Cách đặt:
+// #bai-giang(gian-dong: 1.25) / #de-toan(gian-dong: 1.25) — toàn tài liệu
+// #gian-dong(1.4) — đổi giữa bài (áp cho các slide/khung sau đó)
+// #tn(..., gian-dong: 1.4) — ghi đè RIÊNG một câu
+// Dùng khi phân số / căn thức / chỉ số chồng tầng làm hai dòng dính nhau.
+// `doan` = khoảng cách GIỮA HAI ĐOẠN (khi nội dung có DÒNG TRỐNG). Phải giãn
+// theo cùng hệ số với `leading`, kẻo dòng nối bằng `\` (cùng một đoạn) thì
+// giãn ra mà chỗ cách bằng dòng trống (sang đoạn mới) lại y nguyên — đó là lý
+// do trước đây #voi-gian-dong "chỉ ăn đoạn đầu".
+// 1.2em là mốc mặc định của Typst ⇒ he-so = 1.0 giữ nguyên bố cục mọi bài cũ.
+#let _doan-nen = 1.2em
+#let _gd = state("bg-gian-dong", (nen: 0.62em, doan: _doan-nen, he-so: 1.0))
+#let _dat-gian(nen, k, doan: _doan-nen) = _gd.update((nen: nen, doan: doan, he-so: k))
+#let gian-dong(k) = _gd.update(g => (
+ nen: g.nen, doan: g.at("doan", default: _doan-nen), he-so: k))
+// Ba helper dưới đây CHỈ gọi được trong `context`.
+#let _he-so-gian() = _gd.get().he-so
+#let _gian-ht() = {
+ let g = _gd.get()
+ g.nen * g.he-so
+}
+#let _gian-doan() = {
+ let g = _gd.get()
+ g.at("doan", default: _doan-nen) * g.he-so
+}
+// Áp hệ số giãn dòng cho MỘT KHỐI nội dung bất kì (phần còn lại không đổi):
+// #voi-gian-dong(1.4)[ ... đoạn nhiều phân số ... ]
+// Giãn CẢ khoảng cách dòng lẫn khoảng cách đoạn.
+#let voi-gian-dong(k, body) = context {
+ let g = _gd.get()
+ set par(leading: g.nen * k, spacing: g.at("doan", default: _doan-nen) * k)
+ body
+}
+
+// ----- CHIỀU CAO THẬT CỦA CÔNG THỨC TRONG DÒNG (chống dính chữ) -----
+// VÌ SAO CẦN: Typst đóng khung công thức TRONG DÒNG theo SỐ ĐO PHÔNG CHỮ
+// (cap-height → đường chân chữ, ≈6.83pt ở cỡ 10pt) chứ KHÔNG theo nét vẽ thật.
+// `measure($1/2$)` và `measure($0,5$)` vì thế ra CÙNG chiều cao, trong khi phân
+// số vẽ ra cao ≈12pt và TRÀN cả trên lẫn dưới khung. Hệ quả:
+// • trong một đoạn: dòng không cao lên ⇒ tử số dòng dưới chạm mẫu dòng trên;
+// • trong ô của `grid` (phương án #tn, ý #ds, `cot-item`): ô cũng lấy chiều
+// cao HỤT đó ⇒ phân số đè sang hàng trên, mà `gian-dong` (chỉ chạm
+// par.leading/par.spacing) KHÔNG với tới được.
+// Trước đây phải bù bằng `gian-dong: 3` — giãn ĐỀU cả bài, chỗ cần thì vừa,
+// chỗ chữ thường thì trống hoác.
+//
+// CÁCH CHỮA: đo nét vẽ thật (top-edge/bottom-edge: "bounds" — xem `co-net` của
+// ve.typ) rồi chèn một CỘT CHỐNG vô hình RỘNG 0 ngay trước công thức, cao đúng
+// phần nét nhô lên/thò xuống. Cột chống là hộp trong dòng nên Typst tính nó vào
+// chiều cao dòng VÀ chiều cao ô ⇒ dòng/ô tự nới ĐÚNG chỗ cần, chữ thường không
+// đổi một pt nào. Vì rộng 0 nên `measure(...).width` (chỗ `cot: auto` chọn số
+// cột) cũng không đổi.
+// CỐ Ý dùng cột chống chứ KHÔNG bọc công thức trong `box`: bọc box sẽ chặn
+// Typst ngắt dòng giữa công thức dài.
+// bat : bật/tắt toàn bộ cơ chế.
+// nguong : nét tràn dưới mức này thì bỏ qua (khỏi chèn cột chống vô ích).
+// them : nới thêm bấy nhiêu ở MỖI phía (chỉ dùng khi muốn thoáng hơn nữa).
+#let _cao-that = state("bg-cao-that", (bat: true, nguong: 1pt, them: 0pt, chia: 0.5, hien: false))
+
+// #cao-that(false) — tắt từ đây trở đi; #cao-that() — bật lại;
+// #cao-that(them: 1pt) — nới thêm 1pt mỗi phía cho mọi công thức có tràn nét.
+// Dùng SINK để nhận `bat` ở dạng ĐỐI SỐ VỊ TRÍ mà vẫn gọi được #cao-that().
+// hien: true — CHẨN ĐOÁN, in phần `thieu` (pt) màu đỏ ngay sau mỗi công thức.
+// Không thấy số đỏ nào hiện ra ⇒ bản lib đang chạy KHÔNG phải bản này.
+#let cao-that(..a, nguong: auto, them: auto, chia: auto, hien: auto) = {
+ let bat = if a.pos().len() > 0 { a.pos().first() } else { true }
+ _cao-that.update(c => (
+ bat: bat,
+ nguong: if nguong == auto { c.at("nguong", default: 1pt) } else { nguong },
+ them: if them == auto { c.at("them", default: 0pt) } else { them },
+ chia: if chia == auto { c.at("chia", default: 0.5) } else { chia },
+ hien: if hien == auto { c.at("hien", default: false) } else { hien },
+ ))
+}
+
+// (tren, duoi) = phần NÉT VẼ nhô lên trên / thò xuống dưới đường chân chữ.
+// CHỈ gọi trong `context`.
+#let _do-net(nd) = (
+ measure({ set text(top-edge: "bounds", bottom-edge: "baseline"); nd }).height,
+ measure({ set text(top-edge: "baseline", bottom-edge: "bounds"); nd }).height,
+)
+
+// ⚠️ KHÔNG tách được nét vẽ thành (trên, dưới) một cách đáng tin. Đã thử và
+// SAI: với công thức CAO (nhất là khi tài liệu bật `math.display` cho công
+// thức trong dòng — kiểu \displaystyle của LaTeX, người dùng rất hay dùng để
+// phân số to đẹp), hai phép đo một phía trả về CÙNG một số (đo được 17.31 /
+// 17.31 cho `$1/2$`), tức mỗi phép đo đã gồm cả hai phía ⇒ cộng lại là nới
+// GẤP ĐÔI. Đừng quay lại lối đó.
+//
+// LỐI ĐÚNG — TỰ HIỆU CHỈNH, chỉ dùng phép đo CẢ DÒNG (đáng tin):
+// du = nét vẽ dòng "mẫu + công thức" trừ nét vẽ dòng "mẫu" → chỗ ink CẦN
+// hop = khung dòng "mẫu + công thức" trừ khung dòng "mẫu" → chỗ Typst CHO
+// thieu = du − hop → phần TRÀN
+// `thieu` chính là phần đè sang hàng trên/dòng trên. Cách này không phụ thuộc
+// vào việc Typst tính khung công thức thế nào, nên đúng với cả math.display,
+// cả phông chữ khác, cả cỡ chữ khác.
+#let _mau-chu = [Ág]
+
+#let _cao-ink(nd) = measure({
+ set text(top-edge: "bounds", bottom-edge: "bounds")
+ nd
+}).height
+
+// Cột chống vô hình + chính công thức. Thiếu ít hơn `nguong` ⇒ TRẢ NGUYÊN.
+// `chia` = phần `thieu` dồn xuống DƯỚI đường chân chữ (0.5 = chia đôi, hợp với
+// phân số vì tử tràn lên còn mẫu tràn xuống gần bằng nhau).
+#let _chong-net(nd) = context {
+ let c = _cao-that.get()
+ if not c.at("bat", default: true) { nd } else {
+ let mau = _mau-chu
+ let ca = [#mau#nd]
+ let du = _cao-ink(ca) - _cao-ink(mau)
+ let hop = measure(ca).height - measure(mau).height
+ let thieu = calc.max(0pt, du - hop)
+ let ng = c.at("nguong", default: 1pt)
+ let them = c.at("them", default: 0pt)
+ let chia = c.at("chia", default: 0.5)
+ // Chỉ chèn cột chống khi thật sự tràn; không tràn thì KHÔNG thêm gì cả.
+ if thieu > ng {
+ box(width: 0pt, height: measure(nd).height + thieu + 2 * them,
+ baseline: thieu * chia + them)
+ }
+ nd
+ // Chẩn đoán: #cao-that(hien: true) -> in số `thieu` màu đỏ sau công thức.
+ if c.at("hien", default: false) {
+ text(size: 6pt, fill: red, weight: "bold")[ (#calc.round(thieu.pt(), digits: 2))]
+ }
+ }
+}
+
+// Áp cơ chế cho MỘT khối bất kì (bai-giang/de-toan đã tự áp cho cả tài liệu):
+// #voi-cao-that[ ... ]
+#let voi-cao-that(body) = {
+ show math.equation.where(block: false): _chong-net
+ body
+}
+
+// ----- Bộ đếm "đóng băng" theo slide -----
+// Slide hoạt hình được in thành nhiều trang lặp; nếu đếm kiểu thường thì
+// "Ví dụ 1" sẽ thành "Ví dụ 2, 3..." qua từng bước. Cách giải quyết:
+// goc = tổng tích luỹ đến HẾT slide trước (state),
+// cnt = đếm trong slide, được ĐẶT LẠI ở mỗi bước lặp,
+// số hiển thị = goc + cnt => không đổi giữa các bước.
+#let _bd-cau = (goc: state("bg-goc-cau", 0), cnt: counter("bg-cnt-cau"))
+#let _bd-vd = (goc: state("bg-goc-vd", 0), cnt: counter("bg-cnt-vd"))
+#let _bd-lt = (goc: state("bg-goc-lt", 0), cnt: counter("bg-cnt-lt"))
+#let _bd-hd = (goc: state("bg-goc-hd", 0), cnt: counter("bg-cnt-hd"))
+#let _bd-vdtt = (goc: state("bg-goc-vdtt", 0), cnt: counter("bg-cnt-vdtt"))
+#let _bd-tat-ca = (_bd-cau, _bd-vd, _bd-lt, _bd-hd, _bd-vdtt)
+
+// Tăng bộ đếm và hiển thị số hiện tại.
+#let _so-moi(bd) = {
+ bd.cnt.step()
+ context [#(bd.goc.get() + bd.cnt.get().first())]
+}
+
+// =====================================================================
+// TRANG BÌA — 5 kiểu (kieu-bia: 1..5 hoặc tên)
+// 1 "toi-gian" Tối giản & Thanh lịch
+// 2 "tre-trung" Trẻ trung & Sáng tạo
+// 3 "co-dien" Chuẩn mực Học thuật Cổ điển
+// 4 "chuyen-nghiep" Chuyên nghiệp & Khoa học
+// 5 "ky-thuat" Tiêu chuẩn cho Tài liệu Kỹ thuật
+// Mỗi kiểu có tông màu MẶC ĐỊNH riêng; nếu người dùng truyền mau-chinh /
+// mau-nhan (khác auto) thì màu đó GHI ĐÈ tông mặc định.
+// =====================================================================
+
+// Tông màu mặc định của từng kiểu bìa: (chính, nhấn).
+#let _bia-mau(kieu) = (
+ (chinh: rgb("#0f4c81"), nhan: rgb("#e67e22")), // 1 tối giản — navy + cam (mặc định, giữ tương thích)
+ (chinh: rgb("#5b2a86"), nhan: rgb("#ff5d73")), // 2 trẻ trung — tím + hồng san hô
+ (chinh: rgb("#14213d"), nhan: rgb("#c8a24b")), // 3 cổ điển — navy + vàng đồng
+ (chinh: rgb("#0f4c81"), nhan: rgb("#17a2b8")), // 4 chuyên nghiệp — xanh + lam ngọc
+ (chinh: rgb("#263238"), nhan: rgb("#ef6c00")), // 5 kỹ thuật — xám lam + cam
+).at(kieu - 1)
+
+// Chuẩn hoá kieu-bia: nhận số 1..5 hoặc chuỗi tên.
+#let _bia-so(k) = {
+ if type(k) == int { return calc.max(1, calc.min(5, k)) }
+ let s = lower(str(k)).replace("-", "").replace("_", "").replace(" ", "")
+ (
+ toigian: 1, thanhlich: 1, minimal: 1,
+ tretrung: 2, sangtao: 2, creative: 2,
+ codien: 3, hocthuat: 3, classic: 3,
+ chuyennghiep: 4, khoahoc: 4, pro: 4,
+ kythuat: 5, tailieu: 5, tech: 5,
+ ).at(s, default: 1)
+}
+
+// Logo: PHẢI truyền image(...) — không nhận chuỗi đường dẫn. Vì image() gọi
+// trong file này (bên trong package) sẽ tìm ảnh CẠNH package, không phải cạnh
+// file bài giảng của người dùng. Bọc image(...) trong file người dùng thì Typst
+// mới tìm đúng thư mục. Truyền chuỗi => panic có hướng dẫn.
+#let _bia-logo(logo, cao) = if logo != none {
+ if type(logo) == str {
+ panic("logo phải bọc image(...): dùng logo: image(\"" + logo + "\") thay cho chuỗi \"" + logo + "\" (ảnh đặt cùng thư mục file .typ của bạn).")
+ }
+ box(height: cao, logo)
+}
+
+// Gom các trường phụ có giá trị thành mảng (nhan, gia-tri).
+#let _bia-truong(mon, lop, gv, ngay) = {
+ let ds = ()
+ if mon != none { ds.push(("Môn", mon)) }
+ if lop != none { ds.push(("Lớp", lop)) }
+ if gv != none { ds.push(("Giáo viên", gv)) }
+ if ngay != none { ds.push(("Ngày", ngay)) }
+ ds
+}
+
+#let _co(x) = x != none
+
+// ---------- Kiểu 1: Tối giản & Thanh lịch ----------
+#let _bia-1(c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay) = {
+ place(rect(width: 100%, height: 100%, fill: white))
+ place(bottom, rect(width: 100%, height: 5pt, fill: n))
+ place(top + right, dx: -2.4cm, dy: 2.2cm, _bia-logo(logo, 1.9cm))
+ place(left + horizon, dx: 2.6cm, block(width: 19.5cm, {
+ rect(width: 42pt, height: 4pt, fill: n)
+ v(18pt)
+ if _co(don-vi) {
+ text(size: 12.5pt, tracking: 2.5pt, fill: luma(45%), upper(don-vi))
+ v(14pt)
+ }
+ text(fill: c, weight: "bold", size: 40pt, tieu-de)
+ if _co(phu-de) { v(10pt); text(size: 19pt, fill: luma(38%), phu-de) }
+ v(22pt)
+ line(length: 6.5cm, stroke: 0.6pt + luma(55%))
+ v(16pt)
+ let ds = _bia-truong(mon, lop, gv, ngay)
+ if ds.len() > 0 {
+ text(size: 14pt, fill: c.darken(5%), ds.map(p =>
+ [#text(fill: luma(50%))[#p.at(0): ]#p.at(1)]).join(text(fill: n)[#h(10pt)•#h(10pt)]))
+ }
+ }))
+}
+
+// ---------- Kiểu 2: Trẻ trung & Sáng tạo ----------
+#let _bia-2(c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay) = {
+ place(rect(width: 100%, height: 100%,
+ fill: gradient.linear(c, n, angle: 40deg)))
+ // Khối hình học trang trí (bán trong suốt).
+ place(top + right, dx: 2.5cm, dy: -3cm,
+ circle(radius: 4.4cm, fill: white.transparentize(80%)))
+ place(bottom + left, dx: -2.6cm, dy: 2.6cm,
+ circle(radius: 3.4cm, fill: white.transparentize(85%)))
+ place(top + left, dx: 3.2cm, dy: 2.4cm,
+ box(rotate(18deg, rect(width: 26pt, height: 26pt, radius: 6pt,
+ fill: white.transparentize(70%)))))
+ place(bottom + right, dx: -3.6cm, dy: -2.4cm,
+ box(rotate(12deg, rect(width: 34pt, height: 34pt, radius: 8pt,
+ fill: white.transparentize(78%)))))
+ place(top + right, dx: -2.2cm, dy: 2cm, _bia-logo(logo, 1.7cm))
+ place(center + horizon, align(center, block(width: 21cm, {
+ if _co(don-vi) {
+ box(fill: white.transparentize(78%), radius: 20pt, inset: (x: 16pt, y: 7pt),
+ text(fill: white, size: 13pt, weight: "medium", tracking: 1pt, upper(don-vi)))
+ v(20pt)
+ }
+ text(fill: white, weight: "bold", size: 42pt, tieu-de)
+ if _co(phu-de) {
+ v(12pt)
+ text(fill: white.transparentize(12%), size: 21pt, phu-de)
+ }
+ v(26pt)
+ let ds = _bia-truong(mon, lop, gv, ngay)
+ if ds.len() > 0 {
+ grid(columns: ds.len(), column-gutter: 12pt,
+ ..ds.map(p => box(fill: white.transparentize(82%), radius: 10pt,
+ inset: (x: 14pt, y: 9pt), align(center, {
+ text(fill: white.transparentize(20%), size: 10pt, upper(p.at(0)))
+ linebreak()
+ text(fill: white, size: 14pt, weight: "bold", p.at(1))
+ }))))
+ }
+ })))
+}
+
+// ---------- Kiểu 3: Chuẩn mực Học thuật Cổ điển ----------
+#let _bia-3(c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay) = {
+ place(rect(width: 100%, height: 100%, fill: rgb("#f6f1e6")))
+ // Khung viền kép cổ điển.
+ place(center + horizon, rect(width: 100% - 1.6cm, height: 100% - 1.6cm,
+ stroke: 1.4pt + c))
+ place(center + horizon, rect(width: 100% - 2.0cm, height: 100% - 2.0cm,
+ stroke: 0.6pt + n))
+ place(center + horizon, align(center, block(width: 20cm, {
+ _bia-logo(logo, 2.1cm)
+ if _co(logo) { v(14pt) }
+ if _co(don-vi) {
+ text(size: 14pt, tracking: 3pt, fill: c, upper(don-vi))
+ v(6pt)
+ }
+ // Đường phân cách trang trí ✦
+ grid(columns: (1fr, auto, 1fr), column-gutter: 12pt, align: horizon,
+ line(length: 100%, stroke: 0.6pt + n),
+ text(fill: n, size: 13pt, "✦"),
+ line(length: 100%, stroke: 0.6pt + n))
+ v(18pt)
+ text(fill: c, weight: "bold", size: 38pt,
+ font: ("Charis SIL", "Noto Serif", "Libertinus Serif"), tieu-de)
+ if _co(phu-de) {
+ v(12pt)
+ text(size: 19pt, style: "italic", fill: c.lighten(15%), phu-de)
+ }
+ v(18pt)
+ grid(columns: (1fr, auto, 1fr), column-gutter: 12pt, align: horizon,
+ line(length: 100%, stroke: 0.6pt + n),
+ text(fill: n, size: 13pt, "✦"),
+ line(length: 100%, stroke: 0.6pt + n))
+ v(20pt)
+ let ds = _bia-truong(mon, lop, gv, ngay)
+ if ds.len() > 0 {
+ text(size: 14pt, fill: c, ds.map(p =>
+ [#emph[#p.at(0):] #p.at(1)]).join(h(18pt)))
+ }
+ })))
+}
+
+// ---------- Kiểu 4: Chuyên nghiệp & Khoa học ----------
+#let _bia-4(c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay) = {
+ place(rect(width: 100%, height: 100%, fill: white))
+ // Dải màu dọc bên trái (~38%).
+ place(left + top, rect(width: 38%, height: 100%,
+ fill: gradient.linear(c, c.darken(30%), angle: 90deg)))
+ place(left + top, dx: 38%, rect(width: 6pt, height: 100%, fill: n))
+ // Nội dung panel trái.
+ place(left + horizon, dx: 1.6cm, block(width: 8cm, {
+ _bia-logo(logo, 2cm)
+ if _co(logo) { v(20pt) }
+ if _co(don-vi) {
+ text(fill: white, size: 15pt, weight: "bold", don-vi)
+ v(16pt)
+ }
+ let ds = _bia-truong(mon, lop, gv, ngay)
+ for p in ds {
+ text(fill: white.transparentize(45%), size: 10.5pt, upper(p.at(0)))
+ linebreak()
+ text(fill: white, size: 14pt, weight: "medium", p.at(1))
+ v(9pt)
+ }
+ }))
+ // Nội dung bên phải.
+ place(left + horizon, dx: 38% + 1.4cm, block(width: 13cm, {
+ rect(width: 46pt, height: 5pt, fill: n)
+ v(16pt)
+ text(fill: c, weight: "bold", size: 34pt, tieu-de)
+ if _co(phu-de) {
+ v(12pt)
+ text(fill: luma(35%), size: 19pt, phu-de)
+ }
+ }))
+}
+
+// ---------- Kiểu 5: Tiêu chuẩn cho Tài liệu Kỹ thuật ----------
+#let _bia-5(c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay) = {
+ place(rect(width: 100%, height: 100%, fill: white))
+ // Thanh đầu trang.
+ place(top, block(width: 100%, height: 1.7cm, fill: c,
+ align(horizon, pad(x: 1.6cm, grid(columns: (1fr, auto), align: horizon + left,
+ text(fill: white, size: 14pt, weight: "bold", tracking: 1pt,
+ if _co(don-vi) { upper(don-vi) } else { [TÀI LIỆU] }),
+ _bia-logo(logo, 1.1cm))))))
+ place(top, dy: 1.7cm, rect(width: 100%, height: 4pt, fill: n))
+ // Nhãn loại tài liệu + tiêu đề.
+ place(left + horizon, dx: 1.7cm, dy: -1.2cm, block(width: 21cm, {
+ box(fill: n, inset: (x: 10pt, y: 4pt), radius: 3pt,
+ text(fill: white, size: 11pt, weight: "bold", tracking: 2pt,
+ font: ("DejaVu Sans Mono", "Consolas"), "TAI-LIEU / DOC"))
+ v(16pt)
+ text(fill: c, weight: "bold", size: 36pt, tieu-de)
+ if _co(phu-de) {
+ v(10pt)
+ text(fill: luma(35%), size: 19pt, phu-de)
+ }
+ v(22pt)
+ // Bảng siêu dữ liệu.
+ let ds = _bia-truong(mon, lop, gv, ngay)
+ if ds.len() > 0 {
+ block(stroke: (left: 3pt + n), inset: (left: 14pt, y: 2pt),
+ grid(columns: (auto, auto), column-gutter: 16pt, row-gutter: 7pt,
+ ..ds.map(p => (
+ text(fill: luma(45%), size: 12.5pt,
+ font: ("DejaVu Sans Mono", "Consolas"), upper(p.at(0)) + " :"),
+ text(fill: c, size: 13.5pt, weight: "medium", p.at(1)),
+ )).flatten()))
+ }
+ }))
+ // Thanh chân trang.
+ place(bottom, block(width: 100%, height: 0.8cm, fill: c.lighten(8%),
+ align(horizon, pad(x: 1.6cm, text(fill: white.transparentize(25%), size: 9.5pt,
+ font: ("DejaVu Sans Mono", "Consolas"),
+ if _co(ngay) { [\/\/ #ngay] } else { [\/\/ bài giảng · typst] })))))
+}
+
+// Dispatch trang bìa theo kiểu.
+#let _ve-bia(kieu, c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay) = {
+ let f = (_bia-1, _bia-2, _bia-3, _bia-4, _bia-5).at(kieu - 1)
+ block(width: 100%, height: 100%,
+ f(c, n, don-vi, logo, tieu-de, phu-de, mon, lop, gv, ngay))
+}
+
+// ---------- Thiết lập tổng ----------
+// (bai-giang: điểm vào chính của hệ thống trình chiếu)
+#let bai-giang(
+ tieu-de: [BÀI GIẢNG TOÁN],
+ tieu-de-ngan: none, // tên bài rút gọn hiện trên DẢI ĐẦU TRANG của mọi
+ // slide (beamer); none => dùng nguyên tieu-de
+ phu-de: none,
+ gv: none,
+ don-vi: none,
+ ngay: none,
+ lop: none, // Lớp/khối hiện trên BÌA (vd "Lớp 12A1")
+ mon: none, // Môn/chương hiện trên BÌA (vd "Đại số & Giải tích")
+ logo: none, // Logo trường trên BÌA: đường dẫn ảnh HOẶC nội dung ảnh
+ kieu-bia: 1, // KIỂU TRANG BÌA: 1..5 hoặc tên
+ // 1 "toi-gian" 2 "tre-trung" 3 "co-dien"
+ // 4 "chuyen-nghiep" 5 "ky-thuat"
+ mau-chinh: auto, // auto = dùng TÔNG MẶC ĐỊNH của kieu-bia; đặt màu để GHI ĐÈ
+ mau-nhan: auto, // auto = dùng tông nhấn mặc định của kieu-bia
+ nen: "trang", // NỀN SLIDE (beamer): "trang" | "kem" | "xanh-nhat"
+ // | "luc-nhat" | "xam" — hoặc màu tuỳ ý: rgb("#fdf8ee")
+ // (chọn tông SÁNG để hình vẽ/bảng nét sẫm còn rõ)
+ // Charis SIL đứng ĐẦU: font của SIL, thiết kế riêng cho dấu CHỒNG tiếng Việt
+ // (Ể Ổ Ữ Ẩ...). Với chữ HOA (tiêu đề đề thi) nó vẫn giữ dấu hỏi/ngã NGUYÊN CỠ
+ // và chồng THẲNG trên dấu mũ; Noto Serif thu nhỏ + đẩy lệch phải, còn
+ // Libertinus/Times vẽ hook nhỏ nằm ngang -> nhìn như mất dấu.
+ phong: ("Charis SIL", "Noto Serif", "Libertinus Serif", "Times New Roman"),
+ co-chu: 19pt,
+ ti-le-chu: 1.0, // HỆ SỐ PHÓNG CỠ CHỮ THÂN NỘI DUNG (toàn file):
+ // 1.0 = giữ nguyên; 1.1 = to hơn 10%; 0.9 = nhỏ đi 10%.
+ // Nhân vào cỡ chữ nền -> mọi phần thân + khung dùng em
+ // (định nghĩa/ví dụ/lời giải...) co giãn theo; các thanh
+ // tiêu đề/header/footer (pt tuyệt đối) giữ nguyên.
+ gian-dong: 1.0, // HỆ SỐ GIÃN DÒNG thân nội dung (toàn file):
+ // 1.0 = mốc mặc định (A4 0.6em, trình chiếu 0.62em);
+ // 1.25 = giãn thêm 25%; 0.9 = thu lại 10%. Tăng khi
+ // phân số/căn thức nhiều tầng làm hai dòng dính nhau.
+ // Đổi giữa bài: #gian-dong(1.4); riêng một câu:
+ // #vd/#tn/#ds/... (gian-dong: 1.4).
+ mau-cong-thuc: auto, // MÀU MỌI CÔNG THỨC/KÍ HIỆU trong $...$ (toàn file):
+ // auto (mặc định) = thừa kế màu chữ xung quanh — thân
+ // bài màu đen nên công thức hiển thị ĐEN, còn công thức
+ // nằm trong tiêu đề/bìa (chữ trắng) vẫn TRẮNG. Đặt một
+ // màu cụ thể, vd rgb("#0f4c81"), để nhuộm TẤT CẢ công
+ // thức theo màu đó (kể cả trong thanh tiêu đề).
+ ho-so: "beamer", // "beamer" | "sach-dethi" | "sach-loigiai"
+ body,
+) = {
+ _ho-so.update(_chuan-hs(ho-so))
+ // Giải màu theo kiểu bìa: auto -> tông mặc định của kieu-bia; nếu người
+ // dùng truyền màu cụ thể thì GHI ĐÈ. mau-chinh/mau-nhan cũng là màu chủ đạo
+ // của toàn slide (header/footer/khung) nên bìa và bài luôn đồng bộ.
+ let _kb = _bia-so(kieu-bia)
+ let _tm = _bia-mau(_kb)
+ let mau-chinh = if mau-chinh == auto { _tm.chinh } else { mau-chinh }
+ let mau-nhan = if mau-nhan == auto { _tm.nhan } else { mau-nhan }
+ // Nền slide: tên preset hoặc màu tuỳ ý; chân trang tự pha đậm hơn một chút.
+ let _giay = if type(nen) == str {
+ (
+ trang: white,
+ kem: rgb("#fdf8ee"),
+ xanhnhat: rgb("#eef4fb"),
+ lucnhat: rgb("#f1f8f2"),
+ xam: rgb("#f4f5f7"),
+ ).at(lower(nen).replace("-", "").replace("_", ""), default: white)
+ } else { nen }
+ _mau.update((
+ chinh: mau-chinh, nhan: mau-nhan,
+ nen: if type(_giay) == color { _giay.darken(3%) } else { rgb("#f7f9fc") },
+ ))
+ _thong-tin.update((
+ tieu-de: tieu-de, gv: gv,
+ ngan: if tieu-de-ngan == none { tieu-de } else { tieu-de-ngan },
+ ))
+ // Cỡ công thức bằng thân; màu: auto = thừa kế (đen ở thân, trắng ở tiêu đề),
+ // hoặc nhuộm theo mau-cong-thuc nếu chỉ định.
+ show math.equation: it => {
+ set text(size: 1em)
+ // Lưu ý: set text(fill) phải ĐI TRƯỚC `it` trong CÙNG nhánh mới áp được;
+ // đặt trong `if {...}` rồi để `it` ngoài thì fill KHÔNG tác dụng.
+ if mau-cong-thuc == auto { it } else {
+ set text(fill: mau-cong-thuc)
+ it
+ }
+ }
+
+ if _la-sach(ho-so) {
+ // ================= BẢN IN A4 (sách / đề) =================
+ // Công tắc đáp án: "loigiai" -> hiện tất cả; ngược lại (dethi) -> ẩn.
+ state("ch-hien-da", false).update(_chuan-hs(ho-so).contains("loigiai"))
+ _buoc-ht.update(1000000) // mọi #lo(n) hiện hết trên bản in
+ set page(
+ paper: "a4",
+ margin: (x: 2cm, top: 2cm, bottom: 2cm),
+ fill: white,
+ footer: context align(center, text(size: 9pt, fill: luma(40%),
+ [Trang #counter(page).display() / #counter(page).final().first()])),
+ )
+ set text(font: phong, size: 11.5pt * ti-le-chu, lang: "vi", fill: rgb("#1c2833"))
+ _dat-gian(0.6em, gian-dong)
+ set par(justify: true, leading: 0.6em * gian-dong, spacing: _doan-nen * gian-dong)
+ // Công thức trong dòng khai ĐÚNG chiều cao nét vẽ ⇒ dòng/ô tự nới đúng chỗ.
+ show math.equation.where(block: false): _chong-net
+ set heading(numbering: none) // không đánh số — chỉ dùng thanh màu
+ // Thanh tiêu đề màu thay cho heading (giữ heading để có bookmark + mục lục).
+ show heading.where(level: 1): it => block(width: 100%, above: 16pt, below: 10pt,
+ block(width: 100%, fill: mau-chinh, inset: (x: 12pt, y: 7pt), radius: 4pt,
+ text(fill: white, weight: "bold", size: 1.15em, it.body)))
+ show heading.where(level: 2): it => block(width: 100%, above: 13pt, below: 7pt, {
+ block(width: 100%, fill: mau-chinh, inset: (x: 10pt, y: 5pt),
+ radius: (top: 4pt, rest: 0pt),
+ text(fill: white, weight: "bold", size: 1.05em, it.body))
+ block(width: 100%, height: 2.5pt, fill: mau-nhan)
+ })
+ // ----- Tiêu đề tài liệu -----
+ align(center, {
+ if don-vi != none { text(size: 12pt, fill: luma(30%), don-vi); linebreak() }
+ text(weight: "bold", size: 17pt, fill: mau-chinh, tieu-de)
+ if phu-de != none { linebreak(); v(2pt); text(size: 12pt, fill: luma(25%), phu-de) }
+ if gv != none { linebreak(); v(3pt); text(size: 11pt, style: "italic", [Giáo viên: #gv]) }
+ if ngay != none { linebreak(); text(size: 10pt, fill: luma(40%), ngay) }
+ })
+ v(4pt)
+ line(length: 100%, stroke: 0.8pt + mau-chinh)
+ v(2pt)
+ body
+ } else {
+ // ================= BẢN TRÌNH CHIẾU 16:9 =================
+ // Bật công tắc đáp án: khi trình chiếu, đáp án luôn được đánh dấu
+ // ở bước lo-da (bước cuối) của mỗi câu. Muốn ẩn hẳn: #tat-dap-an().
+ state("ch-hien-da", false).update(true)
+ set page(paper: "presentation-16-9", margin: 0pt, fill: _giay)
+ set text(font: phong, size: co-chu * ti-le-chu, lang: "vi", fill: rgb("#1c2833"))
+ _dat-gian(0.62em, gian-dong)
+ set par(justify: false, leading: 0.62em * gian-dong, spacing: _doan-nen * gian-dong)
+ show math.equation.where(block: false): _chong-net
+
+ // ----- Trang bìa (1 trong 5 kiểu) -----
+ _ve-bia(_kb, mau-chinh, mau-nhan, don-vi, logo, tieu-de, phu-de,
+ mon, lop, gv, ngay)
+
+ body
+ }
+}
+
+// ---------- Gỡ enum trong tiêu đề ----------
+// Nội dung "[1. Tên mục]" bị Typst parse thành MỤC DANH SÁCH đánh số (enum);
+// khi căn phải/giữa, số "1." bị tách xa phần chữ. Hàm này gỡ về chữ thường.
+#let _go-enum(nd) = {
+ if type(nd) != content { return nd }
+ if nd.func() == enum.item {
+ let so = nd.at("number", default: none)
+ if so == none { nd.body } else { [#so. #nd.body] }
+ } else if nd.func() == list.item {
+ nd.body
+ } else if nd.has("children") {
+ nd.children.map(c => _go-enum(c)).join()
+ } else { nd }
+}
+
+// ---------- Slide mục (chuyển phần) ----------
+// ngan: tên rút gọn hiển thị trên thanh điều hướng (mặc định = ten).
+#let muc(ten, ngan: none) = {
+ let ten = _go-enum(ten)
+ let ngan = _go-enum(ngan)
+ _muc-ht.update(ten)
+ context {
+ if _la-sach(_ho-so.get()) {
+ // Bản in: mở phần mới ở đầu trang, tiêu đề = heading cấp 1 (thanh màu).
+ pagebreak(weak: true)
+ heading(level: 1, ten)
+ } else {
+ pagebreak(weak: true)
+ // mốc điều hướng: tên mục + vị trí (để tạo liên kết)
+ place([#metadata((
+ loai: "muc", ten: ten,
+ ngan: if ngan == none { ten } else { ngan },
+ ))])
+ let m = _mau.get()
+ block(width: 100%, height: 100%, {
+ place(rect(width: 100%, height: 100%, fill: m.chinh))
+ place(left + horizon, rect(width: 10pt, height: 42%, fill: m.nhan))
+ place(center + horizon, align(center,
+ text(fill: white, weight: "bold", size: 30pt, ten)
+ ))
+ })
+ }
+ }
+}
+
+// ---------- Slide thường ----------
+// so-buoc: số bước hoạt hình — slide được in thành so-buoc trang,
+// kết hợp với #lo(n)[...] / #chi(n)[...] / lo-da của câu hỏi.
+// Nội dung dài quá một trang sẽ TỰ NGẮT sang trang kế tiếp:
+// đầu/chân trang lặp lại, số slide và chấm điều hướng giữ nguyên.
+#let slide(tieu-de: none, so-buoc: 1, body) = context {
+ let tieu-de = _go-enum(tieu-de)
+ // Giãn dòng hiện hành (nen × he-so) — đặt lại ở mỗi slide để #gian-dong(k)
+ // gọi giữa bài có tác dụng cho các slide phía sau. Với he-so = 1.0 giá trị
+ // này TRÙNG mốc mặc định nên bố cục không đổi.
+ let _gl = _gian-ht()
+ let _gs = _gian-doan()
+ if _la-sach(_ho-so.get()) {
+ // Bản in A4: thanh tiêu đề màu (heading cấp 2) + nội dung chảy liên tục.
+ if tieu-de != none { heading(level: 2, tieu-de) }
+ block(above: 4pt, below: 12pt, {
+ set par(leading: _gl, spacing: _gs)
+ body
+ })
+ } else {
+ for k in range(1, so-buoc + 1) {
+ set page(
+ margin: (
+ top: if tieu-de != none { _le-slide.tren } else { _le-slide.tren-tron },
+ bottom: _le-slide.duoi, x: 0pt,
+ ),
+ header-ascent: 12pt,
+ footer-descent: 12pt,
+ // đầu trang: thanh tiêu đề (lặp lại nếu slide tràn trang)
+ header: if tieu-de != none {
+ context {
+ let m = _mau.get()
+ let tt = _thong-tin.get()
+ let mh = _muc-ht.get()
+ // Dải trên (đậm hơn): TÊN BÀI HỌC nổi bật trên MỌI slide,
+ // kèm tên mục hiện tại ở góc phải.
+ block(width: 100%, height: 17pt, fill: m.chinh.darken(30%), below: 0pt,
+ align(horizon, pad(x: 26pt, grid(
+ columns: (auto, 1fr), column-gutter: 14pt,
+ align: (left + horizon, right + horizon),
+ if tt.ngan != none {
+ text(fill: white, weight: "bold", size: 10.5pt, tt.ngan)
+ } else { [] },
+ if mh != none {
+ text(fill: white.transparentize(40%), size: 9.5pt, mh)
+ } else { [] },
+ ))))
+ // Thanh tiêu đề slide (như cũ, đủ chỗ cho tiêu đề dài).
+ block(width: 100%, height: 44pt, fill: m.chinh, above: 0pt,
+ align(horizon, pad(x: 26pt,
+ text(fill: white, weight: "bold", size: 21pt, tieu-de))))
+ }
+ },
+ // chân trang: thanh điều hướng + số slide (không tăng theo bước)
+ footer: context {
+ let m = _mau.get()
+ block(width: 100%, height: 22pt, fill: m.nen, {
+ place(top, line(length: 100%, stroke: 0.5pt + m.chinh.lighten(55%)))
+ let tat-ca = query()
+ let mh = _muc-ht.get()
+ let so-ht = _dem-slide.get().first()
+ align(horizon, pad(x: 26pt, grid(
+ columns: (auto, 1fr, auto),
+ column-gutter: 14pt,
+ // tên các mục — bấm để nhảy tới đầu mục
+ {
+ for (i, e) in tat-ca.filter(e => e.value.loai == "muc").enumerate() {
+ if i > 0 { h(11pt) }
+ let ht = e.value.ten == mh
+ link(e.location(), text(size: 9pt,
+ weight: if ht { "bold" } else { "regular" },
+ fill: if ht { m.chinh } else { m.chinh.lighten(42%) },
+ e.value.ngan))
+ }
+ },
+ // chấm tròn: các slide trong mục hiện tại — bấm để nhảy tới slide
+ align(right, {
+ for e in tat-ca.filter(e => e.value.loai == "slide" and e.value.muc == mh) {
+ h(4pt)
+ link(e.location(), box(baseline: 30%, circle(radius: 2.6pt,
+ fill: if e.value.so == so-ht { m.chinh } else { white },
+ stroke: 0.7pt + m.chinh)))
+ }
+ }),
+ text(size: 10pt, fill: m.chinh,
+ [#so-ht / #_dem-slide.final().first()]),
+ )))
+ })
+ },
+ )
+ pagebreak(weak: true)
+ _buoc-ht.update(k)
+ // đặt lại bộ đếm trong-slide ở mỗi bước để số thứ tự không tăng
+ for bd in _bd-tat-ca { bd.cnt.update(0) }
+ // mốc điều hướng (chỉ đặt ở bước 1 — đích của liên kết)
+ if k == 1 {
+ _dem-slide.step()
+ place(context [#metadata((
+ loai: "slide", ten: tieu-de,
+ muc: _muc-ht.get(), so: _dem-slide.get().first(),
+ ))])
+ }
+ pad(x: _le-slide.ngang, {
+ set par(leading: _gl, spacing: _gs)
+ body
+ })
+ // hết slide: cộng dồn số đã dùng vào tổng tích luỹ (đúng một lần)
+ if k == so-buoc {
+ context {
+ let so = _bd-tat-ca.map(bd => bd.cnt.get().first())
+ _bd-cau.goc.update(v => v + so.at(0))
+ _bd-vd.goc.update(v => v + so.at(1))
+ _bd-lt.goc.update(v => v + so.at(2))
+ _bd-hd.goc.update(v => v + so.at(3))
+ _bd-vdtt.goc.update(v => v + so.at(4))
+ }
+ }
+ }
+ }
+}
+
+// ---------- Hoạt hình xuất hiện từng bước ----------
+// Dùng trong slide có so-buoc > 1:
+// #lo(2)[...] — hiện TỪ bước 2. Trước đó KHÔNG chiếm chỗ
+// (như \pause của LaTeX beamer) — nhờ vậy
+// slide dài không sinh trang trắng đệm;
+// nội dung hiện dần từ trên xuống nên bố cục
+// phần đã hiện không đổi giữa các bước.
+// #lo(2, giu-cho: true)[...] — ẩn nhưng vẫn giữ chỗ (khi cần cố định
+// vị trí phần tử phía dưới; chỉ nên dùng
+// cho slide chắc chắn gói gọn 1 trang).
+// #chi(2)[...] — CHỈ hiện ở bước 2
+// #chi(2, giu-cho: false)[..] — chỉ hiện ở bước 2, không giữ chỗ
+#let lo(n, giu-cho: false, body) = context {
+ if _la-sach(_ho-so.get()) or _buoc-ht.get() >= n { body }
+ else if giu-cho { hide(body) }
+}
+
+#let chi(n, giu-cho: true, body) = context {
+ if _la-sach(_ho-so.get()) { body } // bản in: hiện hết
+ else if _buoc-ht.get() == n { body } else if giu-cho { hide(body) }
+}
+
+// ---------- Hoạt hình BIẾN MẤT từng bước (đối xứng #lo) ----------
+// Như hiệu ứng "thoát" (exit) của PowerPoint: phần tử hiện từ đầu rồi
+// BIẾN MẤT khi tới bước n.
+// #an(2)[...] — hiện ở bước 1, BIẾN MẤT từ bước 2 trở đi.
+// Mặc định KHÔNG giữ chỗ: nội dung phía dưới
+// dồn lên lấp chỗ (như #lo).
+// #an(2, giu-cho: true)[...] — biến mất nhưng CHỪA khoảng trống, bố cục
+// phía dưới đứng yên (dùng cho slide gói gọn
+// một trang).
+// Kết hợp #lo + #an để thay thế phần tử tại một vị trí, ví dụ hiện đáp án
+// sai ở bước 2 rồi xoá đi ở bước 3 và cho đáp án đúng hiện lên:
+// #an(3)[đáp án tạm] #lo(3)[đáp án đúng]
+// Bản in (dethi/loigiai) HIỆN HẾT như #lo — muốn bản in chỉ giữ trạng thái
+// cuối thì đặt phần biến mất trong #chi (chỉ hiện đúng một bước).
+#let an(n, giu-cho: false, body) = context {
+ if _la-sach(_ho-so.get()) or _buoc-ht.get() < n { body }
+ else if giu-cho { hide(body) }
+}
+
+// Hiện TRONG MỘT KHOẢNG bước: xuất hiện ở bước `tu`, biến mất ở bước `den`
+// (khoảng nửa mở [tu, den) — tức hiện ở các bước tu..den−1). Bằng đúng
+// #lo(tu) + #an(den) gộp lại.
+// #hien-khoang(2, 4)[...] — hiện ở bước 2 và 3, biến mất từ bước 4.
+#let hien-khoang(tu, den, giu-cho: false, body) = context {
+ if _la-sach(_ho-so.get()) { body } // bản in: hiện hết
+ else {
+ let k = _buoc-ht.get()
+ if k >= tu and k < den { body }
+ else if giu-cho { hide(body) }
+ }
+}
+
+// Danh sách hiện dần từng ý; ý đầu tiên xuất hiện ở bước `tu`.
+// #tung-buoc([ý 1], [ý 2], [ý 3]) // trong slide có so-buoc đủ lớn
+#let tung-buoc(tu: 2, ..cac-y) = {
+ for (i, y) in cac-y.pos().enumerate() {
+ lo(tu + i, block(above: 6pt, below: 6pt, y))
+ }
+}
+
+// ---------- Khung nội dung ----------
+// Hai block rời (đầu khung "dính" với thân) thay vì stack,
+// để khung dài có thể tự ngắt sang trang/slide kế tiếp.
+#let _khung(nhan, mau, body) = {
+ block(
+ width: 100%, fill: mau, inset: (x: 12pt, y: 6.5pt),
+ radius: (top: 5pt), above: 11pt, below: 0pt, sticky: true,
+ text(fill: white, weight: "bold", size: 0.78em, nhan),
+ )
+ block(
+ width: 100%, fill: mau.lighten(94%), inset: (x: 12pt, y: 10pt),
+ radius: (bottom: 5pt), stroke: 1pt + mau, above: 0pt, below: 11pt,
+ body,
+ )
+}
+
+#let dinh-nghia(body, ten: none) = _khung(
+ if ten == none { [ĐỊNH NGHĨA] } else { [ĐỊNH NGHĨA — #ten] },
+ rgb("#0f4c81"), body,
+)
+
+#let dinh-ly(body, ten: none) = _khung(
+ if ten == none { [ĐỊNH LÝ] } else { [ĐỊNH LÝ — #ten] },
+ rgb("#a93226"), body,
+)
+
+#let tinh-chat(body, ten: none) = _khung(
+ if ten == none { [TÍNH CHẤT] } else { [TÍNH CHẤT — #ten] },
+ rgb("#6c3483"), body,
+)
+
+#let cong-thuc(body, ten: none) = _khung(
+ if ten == none { [CÔNG THỨC] } else { [CÔNG THỨC — #ten] },
+ rgb("#148f77"), body,
+)
+
+#let vi-du(body, ten: none) = _khung(
+ {
+ [VÍ DỤ ]
+ _so-moi(_bd-vd)
+ if ten != none { [ — #ten] }
+ },
+ rgb("#1e8449"), body,
+)
+
+#let luyen-tap(body, ten: none) = _khung(
+ {
+ [LUYỆN TẬP ]
+ _so-moi(_bd-lt)
+ if ten != none { [ — #ten] }
+ },
+ rgb("#b9770e"), body,
+)
+
+// Lời giải: khung trắng viền trái.
+#let loi-giai(body) = block(
+ width: 100%, inset: (left: 12pt, y: 6pt, right: 6pt),
+ stroke: (left: 2.5pt + rgb("#1e8449")),
+ above: 10pt, below: 10pt,
+ {
+ align(center, text(fill: rgb("#1e8449"), weight: "bold", size: 0.82em, [Lời giải]))
+ body
+ },
+)
+
+#let chu-y(body) = block(
+ width: 100%, radius: 5pt, fill: rgb("#fef9e7"),
+ stroke: 1pt + rgb("#f1c40f"), inset: (x: 12pt, y: 9pt),
+ above: 11pt, below: 11pt,
+ {
+ text(fill: rgb("#b7950b"), weight: "bold", size: 0.82em, [⚠ Chú ý. ])
+ body
+ },
+)
+
+#let ghi-nho(body) = block(
+ width: 100%, radius: 5pt, fill: rgb("#eaf2f8"),
+ stroke: (left: 3pt + rgb("#0f4c81")), inset: (x: 12pt, y: 9pt),
+ above: 11pt, below: 11pt,
+ {
+ text(fill: rgb("#0f4c81"), weight: "bold", size: 0.82em, [★ Ghi nhớ. ])
+ body
+ },
+)
+
+// Nhận xét (mục "Nhận xét" trong SGK): khung nhẹ nền lục nhạt, viền trái.
+#let nhan-xet(body) = block(
+ width: 100%, radius: 5pt, fill: rgb("#e8f6f3"),
+ stroke: (left: 3pt + rgb("#148f77")), inset: (x: 12pt, y: 9pt),
+ above: 11pt, below: 11pt,
+ {
+ text(fill: rgb("#117a65"), weight: "bold", size: 0.82em, [✎ Nhận xét. ])
+ body
+ },
+)
+
+// ---------- Bố cục ----------
+// Hai (hoặc nhiều) cột: #chia-cot(a, b) hoặc #chia-cot(a, b, ti-le: (3fr, 2fr))
+#let chia-cot(ti-le: auto, khoang: 16pt, ..noi-dung) = {
+ let cells = noi-dung.pos()
+ grid(
+ columns: if ti-le == auto { (1fr,) * cells.len() } else { ti-le },
+ column-gutter: khoang,
+ ..cells,
+ )
+}
+
+// Danh sách bước giải đánh số tròn.
+// hien-dan: true => trong slide có so-buoc, các bước lần lượt xuất hiện
+// từ bước hoạt hình `tu` (mặc định bước 2).
+#let buoc(hien-dan: false, tu: 2, ..cac-buoc) = {
+ for (i, b) in cac-buoc.pos().enumerate() {
+ let nd = block(above: 7pt, below: 7pt, {
+ box(baseline: 22%, circle(radius: 9pt, fill: rgb("#0f4c81"),
+ align(center + horizon, text(fill: white, size: 11pt, weight: "bold", str(i + 1)))))
+ h(8pt)
+ b
+ })
+ if hien-dan { lo(tu + i, nd) } else { nd }
+ }
+}
+
+// ---------- Slide mục lục điều hướng ----------
+// Tự liệt kê mọi mục và tiêu đề slide; bấm vào là nhảy thẳng tới nơi.
+// Đặt ngay sau trang bìa: #muc-luc()
+#let muc-luc(tieu-de: [Nội dung bài học], cot-so: 2) = context {
+ if _la-sach(_ho-so.get()) {
+ // Bản in: mục lục tự sinh từ các thanh tiêu đề (heading cấp 1 & 2).
+ block(above: 8pt, below: 12pt, {
+ text(weight: "bold", size: 1.1em, fill: _mau.get().chinh, tieu-de)
+ v(5pt)
+ outline(title: none, depth: 2, indent: 1.2em)
+ })
+ } else {
+ slide(tieu-de: tieu-de)[
+ #context {
+ let m = _mau.get()
+ let tat-ca = query()
+ let cac-muc = tat-ca.filter(e => e.value.loai == "muc")
+ grid(
+ columns: (1fr,) * cot-so,
+ column-gutter: 24pt, row-gutter: 18pt,
+ ..cac-muc.map(mm => block({
+ link(mm.location(), text(weight: "bold", size: 0.88em, fill: m.chinh, mm.value.ten))
+ for s in tat-ca.filter(e => e.value.loai == "slide" and e.value.muc == mm.value.ten) {
+ block(above: 5pt, below: 0pt, link(s.location(), text(size: 0.74em, {
+ text(fill: m.nhan, [▸ ])
+ if s.value.ten == none { [Slide #s.value.so] } else { s.value.ten }
+ })))
+ }
+ })),
+ )
+ }
+ ]
+ }
+}
+
+// Trang kết thúc.
+#let trang-cam-on(loi: [Cảm ơn các em đã tích cực hợp tác!]) = context {
+ if _la-sach(_ho-so.get()) {
+ // Bản in: kết thúc gọn, không tạo trang bìa cuối.
+ v(8pt)
+ line(length: 100%, stroke: 0.5pt + luma(60%))
+ align(center, text(style: "italic", fill: luma(40%), loi))
+ } else {
+ pagebreak(weak: true)
+ let m = _mau.get()
+ block(width: 100%, height: 100%, {
+ place(rect(width: 100%, height: 100%,
+ fill: gradient.linear(m.chinh, m.chinh.darken(35%), angle: 55deg)))
+ place(bottom, rect(width: 100%, height: 8pt, fill: m.nhan))
+ place(center + horizon, text(fill: white, weight: "bold", size: 30pt, loi))
+ })
+ }
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/so-do-cay.typ b/packages/preview/conic-toan/0.3.4/lib/so-do-cay.typ
new file mode 100644
index 0000000000..5c65ac4cac
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/so-do-cay.typ
@@ -0,0 +1,156 @@
+// =====================================================================
+// so-do-cay.typ — SƠ ĐỒ CÂY (bài toán xác suất, đếm...)
+// Cây tổng quát, sâu tuỳ ý. Mỗi nút là ô bo tròn, cạnh có nhãn xác suất,
+// nút lá có thể kèm ô KẾT QUẢ (cột phải cùng hàng).
+//
+// #so-do-cay(
+// goc: $1$,
+// nhanh: (
+// nut($A$, xs: $1/6$, con: (
+// nut($B$, xs: $1/2$, kq: $A B: 1/12$),
+// nut($overline(B)$, xs: $1/2$, kq: $A overline(B): 1/12$),
+// )),
+// nut($overline(A)$, xs: $5/6$, con: (
+// nut($B$, xs: $1/3$, kq: $overline(A) B: 5/18$),
+// nut($overline(B)$, xs: $2/3$, kq: $overline(A) overline(B): 5/9$),
+// )),
+// ),
+// )
+// =====================================================================
+#import "ve.typ": *
+
+// Một nút: ten (nội dung ô), xs (nhãn trên cạnh đi tới nút),
+// kq (ô kết quả bên phải — thường dùng cho nút lá), con (mảng nút con),
+// mau (màu nền ô; auto = theo cấp).
+#let nut(ten, xs: none, kq: none, con: (), mau: auto) = (
+ ten: ten, xs: xs, kq: kq, con: con, mau: mau,
+)
+
+// Ô chữ bo tròn đặt tâm tại P (toạ độ toán).
+// Kích thước ô lấy theo max(khung, biên nét chữ): công thức trong dòng như
+// $1/6$ bị Typst tính hụt chiều cao khung nên nếu chỉ đo khung thì phân số
+// sẽ thò ra ngoài ô (xem `co-net` trong ve.typ).
+#let o-bo-tron(ctx, P, nd, to: white, vien: auto, dem-x: 8pt, dem-y: 5pt, bk: 5pt, day: 0.8pt) = context {
+ let s = measure(nd)
+ let b = co-net(nd)
+ let sw = calc.max(s.width, b.width)
+ let sh = calc.max(s.height, b.height)
+ let p = toa(ctx, P)
+ let v = if vien == auto { to.darken(35%) } else { vien }
+ place(
+ dx: p.at(0) - sw / 2 - dem-x,
+ dy: p.at(1) - sh / 2 - dem-y,
+ rect(
+ width: sw + 2 * dem-x, height: sh + 2 * dem-y,
+ radius: bk, fill: to, stroke: day + v,
+ ),
+ )
+ place(dx: p.at(0) - s.width / 2, dy: p.at(1) - s.height / 2, nd)
+}
+
+// ---------- duyệt cây (nội bộ) ----------
+#let _la-so(n) = if n.con.len() == 0 { 1 } else { n.con.map(_la-so).sum() }
+#let _sau-max(n) = if n.con.len() == 0 { 0 } else { 1 + calc.max(..n.con.map(_sau-max)) }
+
+// Trả về (y: tung độ tâm, cuoi: y0 còn lại, nut/canh/kq: dữ liệu vẽ).
+#let _duyet(n, sau, y0) = {
+ if n.con.len() == 0 {
+ (y: y0 - 0.5, cuoi: y0 - 1, nut: (), canh: (), kq: ())
+ } else {
+ let yc = y0
+ let nuts = ()
+ let canhs = ()
+ let kqs = ()
+ let ys = ()
+ for c in n.con {
+ let r = _duyet(c, sau + 1, yc)
+ yc = r.cuoi
+ ys.push(r.y)
+ nuts += r.nut
+ canhs += r.canh
+ kqs += r.kq
+ nuts.push((P: (sau + 1, r.y), nd: c.ten, sau: sau + 1, mau: c.mau))
+ if c.kq != none { kqs.push((y: r.y, nd: c.kq)) }
+ }
+ let y = (ys.first() + ys.last()) / 2
+ for (i, c) in n.con.enumerate() {
+ canhs.push((A: (sau, y), B: (sau + 1, ys.at(i)), xs: c.xs))
+ }
+ (y: y, cuoi: yc, nut: nuts, canh: canhs, kq: kqs)
+ }
+}
+
+#let so-do-cay(
+ goc: $1$,
+ nhanh: (), // mảng nut(...) cấp 1
+ w: 11.5cm,
+ cao-hang: 1.5cm, // khoảng cách dọc giữa hai lá kề nhau
+ cach-kq: 0.55, // cột kết quả cách cột lá (đơn vị cột)
+ mau-cap: (rgb("#cfe9f5"), rgb("#dcd7f5"), rgb("#d5eed5"), rgb("#fdeacd")),
+ mau-kq: rgb("#fdecec"), vien-kq: rgb("#e08a8a"),
+ mau-canh: black, day-canh: 0.9pt,
+ co-chu: 10pt,
+ dem-x: 8pt, dem-y: 5pt, // đệm trong của ô
+ them: none, // ctx => vẽ thêm
+) = {
+ let root = nut(goc, con: nhanh)
+ let L = _la-so(root)
+ let D = _sau-max(root)
+ let r = _duyet(root, 0, L)
+ let cac-nut = r.nut + ((P: (0, r.y), nd: root.ten, sau: 0, mau: root.mau),)
+ let co-kq = r.kq.len() > 0
+ let xkq = D + cach-kq
+ let xmax = if co-kq { xkq + 1.0 } else { D + 0.55 }
+ hinh(
+ w: w, h: cao-hang * L,
+ xmin: -0.55, xmax: xmax, ymin: 0, ymax: L,
+ co-chu: co-chu,
+ ctx => context {
+ // cạnh + nhãn xác suất (đầu mút lùi vào theo bề rộng ô đã đo)
+ for c in r.canh {
+ let ncha = cac-nut.find(k => k.P == c.A)
+ let ncon = cac-nut.find(k => k.P == c.B)
+ let wa = if ncha != none { measure(ncha.nd).width / 2 + dem-x + 3pt } else { 0pt }
+ let wb = if ncon != none { measure(ncon.nd).width / 2 + dem-x + 3pt } else { 0pt }
+ let pa = toa-pt(ctx, c.A)
+ let pb = toa-pt(ctx, c.B)
+ let A2 = toa-nguoc(ctx, (pa.at(0) + wa.pt(), pa.at(1)))
+ let B2 = toa-nguoc(ctx, (pb.at(0) - wb.pt(), pb.at(1)))
+ mui-ten(ctx, A2, B2, mau: mau-canh, day: day-canh, kich: 6pt)
+ if c.xs != none {
+ // Nhánh đi XUỐNG (nút con thấp hơn nút cha) đặt nhãn phía DƯỚI cạnh
+ // cho cân đối và khỏi chen vào chỗ rẽ; nhánh đi lên/ngang giữ phía
+ // trên như cũ. Chữ vẫn quay theo cạnh (đọc xuôi).
+ let xuong = c.B.at(1) < c.A.at(1)
+ nhan(
+ ctx, trung-diem(A2, B2), c.xs,
+ huong: if xuong { "below" } else { "above" },
+ cach: 3pt, quay: goc-truc(ctx, A2, B2),
+ )
+ }
+ }
+ // các ô nút
+ for k in cac-nut {
+ let to = if k.mau == auto { mau-cap.at(calc.rem(k.sau, mau-cap.len())) } else { k.mau }
+ o-bo-tron(ctx, k.P, k.nd, to: to, dem-x: dem-x, dem-y: dem-y)
+ }
+ // cột kết quả
+ for q in r.kq {
+ let s = measure(q.nd)
+ let b = co-net(q.nd)
+ let sw = calc.max(s.width, b.width)
+ let sh = calc.max(s.height, b.height)
+ let p = toa(ctx, (xkq, q.y))
+ place(
+ dx: p.at(0), dy: p.at(1) - sh / 2 - dem-y,
+ rect(
+ width: sw + 2.4 * dem-x, height: sh + 2 * dem-y,
+ radius: 5pt, fill: mau-kq, stroke: 0.8pt + vien-kq,
+ ),
+ )
+ place(dx: p.at(0) + 1.2 * dem-x, dy: p.at(1) - s.height / 2, q.nd)
+ }
+ if them != none { them(ctx) }
+ },
+ )
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/tron-xoay.typ b/packages/preview/conic-toan/0.3.4/lib/tron-xoay.typ
new file mode 100644
index 0000000000..9606d03a99
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/tron-xoay.typ
@@ -0,0 +1,359 @@
+// =====================================================================
+// tron-xoay.typ — MIỀN PHẲNG & KHỐI TRÒN XOAY (Giải tích 12)
+//
+// #khoi-tron-xoay(x => calc.sqrt(x), 0, 4)
+// -> TRÁI: miền phẳng giới hạn bởi y = f(x), trục hoành, x = a, x = b
+// (đã tô màu); PHẢI: khối tròn xoay sinh ra khi quay miền đó quanh Ox.
+//
+// Tham số chính:
+// truc : "Ox" (mặc định) hoặc "Oy". Với "Oy" thì f là BÁN KÍNH theo y,
+// tức miền giới hạn bởi x = f(y), trục tung, y = a, y = b.
+// hien : "ca-hai" (mặc định) | "mien" (chỉ miền phẳng) | "khoi" (chỉ khối)
+// g : biên trong (khối rỗng — vành khăn), none = khối đặc
+// mat-cat : c -> vẽ thiết diện (đĩa/vành khăn) tại x = c kèm bán kính
+// the-tich : true -> ghi công thức thể tích dưới hình
+//
+// Hàm lõi `ve-khoi-xoay(ctx, f, a, b, ...)` vẽ RIÊNG khối vào một khung
+// #hinh có sẵn (gọi được không cần ctx như mọi hàm vẽ khác).
+// =====================================================================
+#import "ve.typ": *
+#import "do-thi.typ": so-toan
+
+// ---------- Tiện ích nội bộ ----------
+// Điểm trong hệ "chuẩn hoá": u dọc theo TRỤC QUAY, v vuông góc trục quay.
+#let _pxy(ngang, u, v) = if ngang { (u, v) } else { (v, u) }
+
+// Điểm trên đường tròn thiết diện đã chiếu thành elip (k = tỉ lệ bán trục):
+// t = 0° đỉnh trên · 90° mép "sau" · 180° đỉnh dưới · 270° mép "trước"
+#let _e-diem(ngang, k, u, R, t) = _pxy(
+ ngang,
+ u + k * R * calc.sin(t),
+ R * calc.cos(t),
+)
+
+// Cung elip thiết diện từ t1 đến t2.
+#let _cung-td(
+ ctx, ngang, k, u, R, t1, t2,
+ mau: black, day: 1pt, dut: false, n: 40,
+) = {
+ if R <= 0.0001 { return }
+ duong-cong(
+ ctx,
+ range(n + 1).map(i => _e-diem(ngang, k, u, R, t1 + (t2 - t1) * i / n)),
+ mau: mau, day: day, dut: dut,
+ )
+}
+
+// Elip thiết diện ĐẦY ĐỦ (dùng để tô).
+#let _to-td(ctx, ngang, k, u, R, mau) = {
+ if R <= 0.0001 { return }
+ da-giac-pt(
+ range(49).map(i => toa-pt(ctx, _e-diem(ngang, k, u, R, 360deg * i / 48))),
+ to: mau,
+ )
+}
+
+// Giá trị lớn nhất của |f| trên [a, b] (lấy mẫu).
+#let _ban-kinh-max(f, a, b, n: 120) = calc.max(
+ 0.0001,
+ ..range(n + 1).map(i => calc.abs(f(a + (b - a) * i / n))),
+)
+
+// Hệ trục cho hình: trục quay (có mũi tên + tên) và trục vuông góc nếu 0 thuộc
+// phạm vi. `ngang = true` -> trục quay là Ox.
+#let _truc-xoay(
+ ctx, ngang, umin, umax, vmin, vmax,
+ mau: black, day: 0.9pt, ten-goc: $O$,
+) = {
+ let (ten-u, ten-v) = if ngang { ($x$, $y$) } else { ($y$, $x$) }
+ mui-ten(ctx, _pxy(ngang, umin, 0), _pxy(ngang, umax, 0), mau: mau, day: day)
+ nhan(
+ ctx, _pxy(ngang, umax, 0), ten-u,
+ huong: if ngang { "duoi-phai" } else { "tren-phai" }, cach: 3pt,
+ )
+ if vmin <= 0 and vmax >= 0 and umin <= 0 and umax >= 0 {
+ mui-ten(ctx, _pxy(ngang, 0, vmin), _pxy(ngang, 0, vmax), mau: mau, day: day)
+ nhan(
+ ctx, _pxy(ngang, 0, vmax), ten-v,
+ huong: if ngang { "tren-phai" } else { "duoi-phai" }, cach: 3pt,
+ )
+ if ten-goc != none {
+ nhan(ctx, (0, 0), ten-goc, huong: "duoi-trai", cach: 4pt, mau: mau)
+ }
+ }
+}
+
+// =====================================================================
+// ve-khoi-xoay — vẽ KHỐI TRÒN XOAY vào khung #hinh có sẵn
+// (trục quay đi qua gốc; `ngang: true` = quay quanh Ox, false = quanh Oy)
+// =====================================================================
+#let ve-khoi-xoay(
+ ctx, f, a, b,
+ g: none, // bán kính trong (vành khăn); none = đặc
+ ngang: true,
+ k: 0.26, // tỉ lệ bán trục ngang của elip / bán kính
+ mau: blue, day: 1.2pt,
+ mau-to: rgb(70, 130, 200, 60),
+ duong-sinh: true, // vẽ biên trên/dưới của khối
+ truc-mo: true, // đoạn trục quay bên trong khối vẽ nét đứt
+ mat-cat: none, // vẽ thiết diện tại u = c
+ mau-mat-cat: rgb(235, 130, 40, 120),
+ ten-ban-kinh: auto, // nhãn bán kính thiết diện (none = bỏ)
+ ten-mat-cat: auto, // nhãn mốc c trên trục (none = bỏ)
+ n: 120,
+) = {
+ let R = u => calc.abs(f(u))
+ let Rt = if g == none { u => 0.0 } else { u => calc.abs(g(u)) }
+ let Ra = R(a)
+ let Rb = R(b)
+ let M = _ban-kinh-max(f, a, b)
+ let tren = range(n + 1).map(i => {
+ let u = a + (b - a) * i / n
+ _pxy(ngang, u, R(u))
+ })
+ let duoi = range(n + 1).map(i => {
+ let u = b + (a - b) * i / n
+ _pxy(ngang, u, -R(u))
+ })
+ // ---- tô khối: nắp trái (nửa ngoài) + biên trên + nắp phải + biên dưới
+ let nap-t = range(41).map(i => _e-diem(ngang, k, a, Ra, 180deg + 180deg * i / 40))
+ let nap-p = range(41).map(i => _e-diem(ngang, k, b, Rb, 180deg * i / 40))
+ da-giac-pt((nap-t + tren + nap-p + duoi).map(P => toa-pt(ctx, P)), to: mau-to)
+ // ---- trục quay bên trong khối (bị che -> nét đứt)
+ if truc-mo {
+ doan(
+ ctx, _pxy(ngang, a, 0), _pxy(ngang, b, 0),
+ mau: black, day: 0.7pt, dut: true,
+ )
+ }
+ // ---- thiết diện
+ if mat-cat != none {
+ let c = mat-cat
+ let Rc = R(c)
+ _to-td(ctx, ngang, k, c, Rc, mau-mat-cat)
+ if g != none { _to-td(ctx, ngang, k, c, Rt(c), white) }
+ _cung-td(ctx, ngang, k, c, Rc, 0deg, 360deg, mau: mau.darken(10%), day: 0.9pt)
+ if g != none {
+ _cung-td(ctx, ngang, k, c, Rt(c), 0deg, 360deg, mau: mau.darken(10%), day: 0.9pt)
+ }
+ doan(ctx, _pxy(ngang, c, 0), _pxy(ngang, c, Rc), mau: red.darken(10%), day: 0.9pt)
+ if ten-ban-kinh != none {
+ let nd = if ten-ban-kinh == auto {
+ if ngang { $f(x)$ } else { $f(y)$ }
+ } else { ten-ban-kinh }
+ nhan(
+ ctx, _e-diem(ngang, k, c, Rc, 90deg + 45deg), nd,
+ huong: if ngang { "phai" } else { "duoi" },
+ cach: 3pt, mau: red.darken(10%),
+ )
+ }
+ if ten-mat-cat != none {
+ let nd = if ten-mat-cat == auto { so-toan(c) } else { ten-mat-cat }
+ doan(
+ ctx, _pxy(ngang, c, -Rc), _pxy(ngang, c, -M - 0.3),
+ mau: gray.darken(20%), day: 0.7pt, dut: true,
+ )
+ nhan(
+ ctx, _pxy(ngang, c, -M - 0.3), nd,
+ huong: if ngang { "duoi" } else { "trai" }, cach: 3pt,
+ )
+ }
+ }
+ // ---- đường sinh (biên trên / biên dưới)
+ if duong-sinh {
+ duong-cong(ctx, tren, mau: mau, day: day)
+ duong-cong(ctx, duoi, mau: mau, day: day)
+ }
+ // ---- nắp hai đầu: trái nửa trong bị che (đứt), phải thấy trọn
+ _cung-td(ctx, ngang, k, a, Ra, 180deg, 360deg, mau: mau, day: day)
+ _cung-td(ctx, ngang, k, a, Ra, 0deg, 180deg, mau: mau, day: 0.8pt, dut: true)
+ _cung-td(ctx, ngang, k, b, Rb, 0deg, 360deg, mau: mau, day: day)
+ // ---- khối rỗng: mặt trong + lỗ ở nắp phải
+ if g != none {
+ _to-td(ctx, ngang, k, b, Rt(b), white)
+ _cung-td(ctx, ngang, k, b, Rt(b), 0deg, 360deg, mau: mau, day: day)
+ _cung-td(ctx, ngang, k, a, Rt(a), 0deg, 360deg, mau: mau, day: 0.8pt, dut: true)
+ for s in (1, -1) {
+ duong-cong(
+ ctx,
+ range(n + 1).map(i => {
+ let u = a + (b - a) * i / n
+ _pxy(ngang, u, s * Rt(u))
+ }),
+ mau: mau, day: 0.9pt, dut: true,
+ )
+ }
+ }
+}
+
+// =====================================================================
+// ve-mien-xoay — vẽ MIỀN PHẲNG sinh ra khối (tô màu + cận a, b)
+// =====================================================================
+#let ve-mien-xoay(
+ ctx, f, a, b,
+ g: none,
+ ngang: true,
+ mau: blue, day: 1.3pt,
+ mau-to: rgb(70, 130, 200, 90),
+ ten-ham: auto, ten-ham-trong: none,
+ ten-a: auto, ten-b: auto,
+ giong-ab: true,
+ n: 120,
+) = {
+ let R = u => calc.abs(f(u))
+ let Rt = if g == none { u => 0.0 } else { u => calc.abs(g(u)) }
+ let tren = range(n + 1).map(i => {
+ let u = a + (b - a) * i / n
+ _pxy(ngang, u, R(u))
+ })
+ let duoi = range(n + 1).map(i => {
+ let u = b + (a - b) * i / n
+ _pxy(ngang, u, Rt(u))
+ })
+ da-giac-pt((tren + duoi).map(P => toa-pt(ctx, P)), to: mau-to)
+ duong-cong(ctx, tren, mau: mau, day: day)
+ if g != none { duong-cong(ctx, duoi, mau: mau.darken(25%), day: day) }
+ if giong-ab {
+ for (u, t) in ((a, ten-a), (b, ten-b)) {
+ doan(
+ ctx, _pxy(ngang, u, Rt(u)), _pxy(ngang, u, R(u)),
+ mau: gray.darken(25%), day: 0.8pt, dut: true,
+ )
+ // cận trùng gốc toạ độ thì bỏ nhãn (kẻo đè chữ O)
+ if t != none and not (t == auto and calc.abs(u) < 0.001) {
+ nhan(
+ ctx, _pxy(ngang, u, 0), if t == auto { so-toan(u) } else { t },
+ huong: if ngang { "duoi" } else { "trai" }, cach: 4pt,
+ )
+ }
+ }
+ }
+ if ten-ham != none {
+ let u = a + (b - a) * 0.78
+ let nd = if ten-ham == auto {
+ if ngang { $y = f(x)$ } else { $x = f(y)$ }
+ } else { ten-ham }
+ nhan(
+ ctx, _pxy(ngang, u, R(u)), nd,
+ huong: if ngang { "tren-phai" } else { "phai" }, cach: 5pt, mau: mau,
+ )
+ }
+ if ten-ham-trong != none and g != none {
+ let u = a + (b - a) * 0.3
+ nhan(
+ ctx, _pxy(ngang, u, Rt(u)), ten-ham-trong,
+ huong: if ngang { "duoi-phai" } else { "trai" }, cach: 5pt, mau: mau.darken(25%),
+ )
+ }
+}
+
+// =====================================================================
+// khoi-tron-xoay — HÌNH HOÀN CHỈNH: miền phẳng -> khối tròn xoay
+// =====================================================================
+#let khoi-tron-xoay(
+ f, a, b,
+ g: none,
+ truc: "Ox",
+ hien: "ca-hai", // "ca-hai" | "mien" | "khoi"
+ w: 6cm,
+ k: 0.26,
+ mau: blue, day: 1.3pt,
+ mau-to: rgb(70, 130, 200, 60),
+ mau-mien: rgb(70, 130, 200, 95),
+ ten-ham: auto, ten-ham-trong: none,
+ ten-a: auto, ten-b: auto,
+ mat-cat: none,
+ ten-mat-cat: auto, ten-ban-kinh: auto,
+ the-tich: false, // ghi công thức thể tích dưới hình
+ nhan-giua: auto, // nội dung giữa 2 hình
+ ten-goc: $O$, // nhãn gốc toạ độ (none = bỏ)
+ co-chu: 10pt,
+ khung: false,
+ them: none, them-mien: none,
+) = {
+ let ngang = truc != "Oy"
+ let M = _ban-kinh-max(f, a, b)
+ let d = calc.abs(b - a)
+ let (a, b) = (calc.min(a, b), calc.max(a, b))
+ let le = 0.16 * calc.max(M, d / 2) + 0.22
+ let e = k * M
+ let umin = a - e - le
+ let umax = b + e + le + 0.12 * d
+ let vmax = M + le
+ let vmin = -M - le - (if mat-cat != none and ten-mat-cat != none { 0.42 } else { 0 })
+ // cửa sổ của hình miền phẳng: chỉ cần chút chỗ dưới trục để ghi cận
+ let vmin-m = -0.4 * M - 0.22
+ let ti-le = w / (umax - umin) // giữ ĐÚNG một tỉ lệ cho cả 2 hình
+
+ let khung-khoi(rong, x1, x2, y1, y2, ve) = hinh(
+ w: rong, xmin: x1, xmax: x2, ymin: y1, ymax: y2,
+ co-chu: co-chu, khung: khung, ve,
+ )
+
+ let h-khoi = {
+ let (x1, x2, y1, y2) = if ngang {
+ (umin, umax, vmin, vmax)
+ } else {
+ (vmin, vmax, umin, umax)
+ }
+ khung-khoi(ti-le * (x2 - x1), x1, x2, y1, y2, ctx => {
+ _truc-xoay(ctx, ngang, umin, umax, vmin, vmax, ten-goc: ten-goc)
+ ve-khoi-xoay(
+ ctx, f, a, b, g: g, ngang: ngang, k: k,
+ mau: mau, day: day, mau-to: mau-to,
+ mat-cat: mat-cat, ten-mat-cat: ten-mat-cat, ten-ban-kinh: ten-ban-kinh,
+ )
+ if them != none { them(ctx) }
+ })
+ }
+
+ let h-mien = {
+ let (x1, x2, y1, y2) = if ngang {
+ (umin, umax, vmin-m, vmax)
+ } else {
+ (vmin-m, vmax, umin, umax)
+ }
+ khung-khoi(ti-le * (x2 - x1), x1, x2, y1, y2, ctx => {
+ _truc-xoay(ctx, ngang, umin, umax, vmin-m, vmax, ten-goc: ten-goc)
+ ve-mien-xoay(
+ ctx, f, a, b, g: g, ngang: ngang,
+ mau: mau, day: day, mau-to: mau-mien,
+ ten-ham: ten-ham, ten-ham-trong: ten-ham-trong,
+ ten-a: ten-a, ten-b: ten-b,
+ )
+ if them-mien != none { them-mien(ctx) }
+ })
+ }
+
+ let giua = if nhan-giua == auto {
+ align(center, {
+ text(size: 0.82 * co-chu)[quay quanh #truc]
+ linebreak()
+ text(size: 1.3 * co-chu)[$arrow.r.long$]
+ })
+ } else { nhan-giua }
+
+ let ct = if the-tich {
+ let (bien, kx) = if ngang { ($x$, $dif x$) } else { ($y$, $dif y$) }
+ let (na, nb) = (so-toan(a), so-toan(b))
+ if g == none {
+ $V = pi integral_(#na)^(#nb) f^2 (#bien) #kx$
+ } else {
+ $V = pi integral_(#na)^(#nb) [f^2 (#bien) - g^2 (#bien)] #kx$
+ }
+ } else { none }
+
+ let than = if hien == "mien" {
+ h-mien
+ } else if hien == "khoi" {
+ h-khoi
+ } else {
+ grid(
+ columns: 3, column-gutter: 10pt, align: horizon,
+ h-mien, giua, h-khoi,
+ )
+ }
+ if ct == none { than } else {
+ align(center, { than; v(2pt); ct })
+ }
+}
diff --git a/packages/preview/conic-toan/0.3.4/lib/ve.typ b/packages/preview/conic-toan/0.3.4/lib/ve.typ
new file mode 100644
index 0000000000..0f5b37344a
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/lib/ve.typ
@@ -0,0 +1,1556 @@
+// =====================================================================
+// ve.typ — ENGINE VẼ HÌNH THUẦN TYPST (không phụ thuộc package nào)
+// Hệ toạ độ toán học: gốc do người dùng chọn, trục y hướng LÊN.
+// Mọi hàm vẽ nhận `ctx` (do #hinh cung cấp) + toạ độ dạng (x, y) là số.
+// Quy ước tham số chung: mau (màu), day (độ dày nét), dut (nét đứt).
+// =====================================================================
+
+// ---------- Khung vẽ ----------
+// Tạo khung vẽ. `ve` là hàm nhận ctx và trả về nội dung vẽ.
+// h: auto => tự tính để 2 trục CÙNG TỈ LỆ (nên giữ auto với hình hình học).
+// Ngăn xếp ctx của các khung hình đang vẽ — cho phép GỌI HÀM VẼ KHÔNG CẦN ctx
+// (xem _voi-ctx ngay dưới hinh). Đẩy vào khi vào khung, lấy ra khi rời khung.
+#let _ctx-ht = state("bg-ctx-ht", ())
+
+/// Khung vẽ: đặt cửa sổ toạ độ toán (xmin..xmax, ymin..ymax) rồi vẽ bên trong.
+/// Mọi lệnh vẽ đặt trong thân hàm này là tự có `ctx`, không phải gõ.
+/// -> content
+#let hinh(
+ w: 8cm,
+ h: auto,
+ xmin: -5, xmax: 5,
+ ymin: -4, ymax: 4,
+ co-chu: 10pt,
+ khung: false, // vẽ viền khung (để căn chỉnh khi soạn)
+ cat: false, // cắt phần tràn ra ngoài khung
+ ve,
+) = {
+ let hh = if h == auto { w * (ymax - ymin) / (xmax - xmin) } else { h }
+ let ctx = (
+ w: w, h: hh,
+ xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax,
+ sx: w / (xmax - xmin),
+ sy: hh / (ymax - ymin),
+ )
+ box(
+ width: w, height: hh,
+ stroke: if khung { 0.4pt + luma(70%) } else { none },
+ clip: cat,
+ {
+ set text(size: co-chu)
+ _ctx-ht.update(s => s + (ctx,))
+ ve(ctx)
+ _ctx-ht.update(s => s.slice(0, -1))
+ },
+ )
+}
+
+// Bọc hàm vẽ f(ctx, ...) thành hàm gọi được KHÔNG CẦN ctx: nếu đối số đầu là
+// ctx thì gọi thẳng (lối cũ), không thì tự lấy ctx của khung hình đang vẽ.
+// Dùng ở baigiang.typ để bọc đồng loạt các hàm vẽ (xem cuối file đó).
+//
+// ⚠ NGĂN XẾP RỖNG — KHÔNG ĐƯỢC panic (xem CLAUDE.md, mục "CTX NGẦM TRONG
+// MEASURE"). Nội dung nằm trong `measure(...)` KHÔNG được dựng vào tài liệu nên
+// mọi `state.update` bên trong nó vô hiệu: `_ctx-ht.get()` trả về giá trị CUỐI
+// tài liệu = () ⇒ trước đây panic, làm beamer chết ở phép đo tự ngắt màn
+// (_cat-man-vua) và ở `cot: auto` của cau-mc. Nay:
+// · trong measure ⇒ BỎ QUA, không vẽ gì. Phép đo KHÔNG đổi vì #hinh là `box`
+// có width/height CỐ ĐỊNH, nét vẽ bên trong đều `place` (không chiếm chỗ);
+// bản in thật thì ngăn xếp đầy đủ nên vẫn vẽ đúng như cũ.
+// · thật sự quên #hinh(...) ⇒ ghi DẤU ĐỎ (dùng `place` nên không phá bố cục)
+// thay cho panic — đủ để thấy mà không giết cả lần biên dịch.
+#let _voi-ctx(f) = (..a) => {
+ let p = a.pos()
+ if p.len() > 0 and type(p.at(0)) == dictionary and "sx" in p.at(0) {
+ f(..a)
+ } else {
+ context {
+ let ds = _ctx-ht.get()
+ if ds.len() > 0 {
+ f(ds.last(), ..a)
+ } else {
+ place(text(fill: red, size: 7pt)[⚠ lệnh vẽ đặt NGOÀI khung hình])
+ }
+ }
+ }
+}
+
+// ---------- Đổi toạ độ ----------
+// Toạ độ toán (x, y) -> toạ độ trang (length, length), gốc trên-trái.
+#let toa(ctx, P) = {
+ let Q = if "bien-doi" in ctx and ctx.bien-doi != none { (ctx.bien-doi)(P) } else { P }
+ ((Q.at(0) - ctx.xmin) * ctx.sx, (ctx.ymax - Q.at(1)) * ctx.sy)
+}
+// Như trên nhưng trả về số float tính bằng pt (để tính toán).
+#let toa-pt(ctx, P) = {
+ let q = toa(ctx, P)
+ (q.at(0).pt(), q.at(1).pt())
+}
+
+// ---------- Phép biến hình (toạ độ toán) ----------
+// Quay điểm P quanh tâm `tam` góc `goc` (dương = ngược chiều kim đồng hồ).
+#let quay-diem(P, tam, goc) = {
+ let dx = P.at(0) - tam.at(0)
+ let dy = P.at(1) - tam.at(1)
+ let c = calc.cos(goc)
+ let s = calc.sin(goc)
+ (tam.at(0) + dx * c - dy * s, tam.at(1) + dx * s + dy * c)
+}
+// Tịnh tiến điểm P theo vectơ v.
+#let tinh-tien-diem(P, v) = (P.at(0) + v.at(0), P.at(1) + v.at(1))
+// Toạ độ CỰC kiểu TikZ: điểm cách `tam` một khoảng `bk`, quay góc `goc`
+// (số trần = ĐỘ, nhận cả kiểu angle 30deg/0.5rad; dương = ngược kim đồng hồ).
+// toa-cuc((0,0), 2, 30) ~ TikZ (30:2)
+/// Toạ độ cực kiểu TikZ: điểm cách `tam` một khoảng `bk`, theo `goc`
+/// (số trần = độ, dương = ngược kim đồng hồ). TikZ `(30:2)` <=> `toa-cuc((0,0), 2, 30)`.
+/// TRẢ VỀ điểm, không vẽ gì.
+#let toa-cuc(tam, bk, goc) = {
+ let g = if type(goc) == angle { goc } else { goc * 1deg }
+ (tam.at(0) + bk * calc.cos(g), tam.at(1) + bk * calc.sin(g))
+}
+// Dựng điểm M sao cho tia AM = tia AB QUAY quanh A một góc lượng giác `goc`
+// (số trần = ĐỘ, dương = ngược kim đồng hồ) và AM = `r`. Nói cách khác, góc
+// định hướng (AB, AM) = `goc`. Dùng dựng đỉnh tam giác biết một cạnh + một góc.
+// let M = dung-diem(A, B, 60, 3) // góc BAM = 60°, AM = 3
+/// TRẢ VỀ điểm M: tia AM là tia AB quay quanh A góc lượng giác `goc`
+/// (số trần = độ, dương ngược kim đồng hồ), độ dài AM = `r`. Không vẽ gì.
+#let dung-diem(A, B, goc, r) = {
+ let g = if type(goc) == angle { goc } else { goc * 1deg }
+ let base = calc.atan2(B.at(0) - A.at(0), B.at(1) - A.at(1))
+ toa-cuc(A, r, base + g)
+}
+// Ghép thêm một phép biến hình f vào ctx (nội bộ; phép cũ làm trước, f làm sau).
+#let _ghep-bien-doi(ctx, f) = {
+ let cu = ctx.at("bien-doi", default: none)
+ ctx + (bien-doi: if cu == none { f } else { P => f((cu)(P)) })
+}
+// Bản sao ctx bị QUAY: mọi hàm vẽ dùng ctx này đều quay quanh `tam` góc `goc`.
+// let cq = ctx-quay(ctx, 30deg, tam: (1, 1)); tam-giac(cq, A, B, C)
+// Bản sao ctx bị TỊNH TIẾN theo vectơ v; ghép nối tiếp được nhiều phép:
+// ctx-tinh-tien(ctx-quay(ctx, 30deg, tam: G), (2, 0)) — quay trước, tịnh tiến sau
+// Lưu ý: duong-tron/elip vẫn đúng dưới 2 phép này (tâm tự biến đổi); riêng
+// gach-vung mô tả miền theo toạ độ GỐC của khung (không qua bien-doi).
+#let ctx-quay(ctx, goc, tam: (0, 0)) = _ghep-bien-doi(ctx, P => quay-diem(P, tam, goc))
+#let ctx-tinh-tien(ctx, v) = _ghep-bien-doi(ctx, P => tinh-tien-diem(P, v))
+
+// Kiểu nét
+#let net(mau, day, dut) = (
+ paint: mau,
+ thickness: day,
+ dash: if dut { "dashed" } else { none },
+)
+
+// ---------- Nguyên thuỷ cấp thấp (toạ độ pt, dùng nội bộ) ----------
+#let doan-pt(p, q, mau: black, day: 1pt, dut: false) = place(
+ line(
+ start: (p.at(0) * 1pt, p.at(1) * 1pt),
+ end: (q.at(0) * 1pt, q.at(1) * 1pt),
+ stroke: net(mau, day, dut),
+ )
+)
+
+// Đa giác tô màu từ toạ độ pt (tự chuẩn hoá về gốc bao).
+#let da-giac-pt(pts, to: black, vien: none) = {
+ let xs = pts.map(p => p.at(0))
+ let ys = pts.map(p => p.at(1))
+ let x0 = calc.min(..xs)
+ let y0 = calc.min(..ys)
+ place(
+ dx: x0 * 1pt, dy: y0 * 1pt,
+ polygon(
+ fill: to, stroke: vien,
+ ..pts.map(p => ((p.at(0) - x0) * 1pt, (p.at(1) - y0) * 1pt)),
+ ),
+ )
+}
+
+// Đầu mũi tên tại b, hướng a->b (toạ độ pt, dùng nội bộ).
+#let _dau-pt(a, b, mau: black, kich: 7pt) = {
+ let dx = b.at(0) - a.at(0)
+ let dy = b.at(1) - a.at(1)
+ let l = calc.sqrt(dx * dx + dy * dy)
+ if l < 0.001 { return }
+ let k = kich.pt()
+ let ux = dx / l
+ let uy = dy / l
+ let px = -uy
+ let py = ux
+ da-giac-pt(
+ (
+ b,
+ (b.at(0) - k * ux + 0.38 * k * px, b.at(1) - k * uy + 0.38 * k * py),
+ (b.at(0) - 0.72 * k * ux, b.at(1) - 0.72 * k * uy),
+ (b.at(0) - k * ux - 0.38 * k * px, b.at(1) - k * uy - 0.38 * k * py),
+ ),
+ to: mau,
+ )
+}
+
+// Đầu mũi tên tại B, hướng A->B.
+#let dau-mui-ten(ctx, A, B, mau: black, kich: 7pt) = _dau-pt(
+ toa-pt(ctx, A), toa-pt(ctx, B), mau: mau, kich: kich,
+)
+
+// ---------- Nguyên thuỷ chính ----------
+// (định nghĩa `doan` nằm NGAY SAU `nhan` vì có tuỳ chọn nhãn giữa đoạn)
+
+// Hướng VUÔNG GÓC với đoạn AB trên TRANG (vectơ đơn vị dùng cho `nhan`):
+// ưu tiên phía trên đoạn; đoạn thẳng đứng thì ra phía trái.
+#let _phap-tuyen(ctx, A, B) = {
+ let a = toa-pt(ctx, A)
+ let b = toa-pt(ctx, B)
+ let (dx, dy) = (b.at(0) - a.at(0), b.at(1) - a.at(1))
+ let l = calc.sqrt(dx * dx + dy * dy)
+ if l < 0.0001 { return (0, -1) }
+ let n = (-dy / l, dx / l)
+ if n.at(1) > 0.000001 { n = (-n.at(0), -n.at(1)) } // xoay lên trên
+ if calc.abs(n.at(1)) <= 0.000001 and n.at(0) > 0 { n = (-1, 0) } // đoạn dọc -> trái
+ n
+}
+
+// Góc xoay (kiểu angle) để chữ NẰM DỌC THEO đoạn/đường A -> B (toạ độ toán),
+// có tính cả tỉ lệ 2 trục sx, sy. Dùng cho nhãn tiệm cận xiên, nhãn dọc theo trục...
+// nhan(ctx, P, $y = x - 1$, quay: goc-truc(ctx, (0, -1), (1, 0)))
+// nhan(ctx, (1, y), $x = 1$, quay: goc-truc(ctx, (1, 0), (1, 1))) // dọc, đọc từ dưới lên
+#let goc-truc(ctx, A, B) = {
+ let dx = (B.at(0) - A.at(0)) * ctx.sx.pt()
+ let dy = (B.at(1) - A.at(1)) * ctx.sy.pt()
+ -calc.atan2(dx, dy)
+}
+
+// ---------- Đo nhãn theo BIÊN NÉT CHỮ ----------
+// Typst tính HỤT khung của công thức TRONG DÒNG: `measure($1/2$)` chỉ trả về
+// chiều cao MỘT DÒNG CHỮ (≈6.8pt ở cỡ 10pt) trong khi phân số vẽ ra cao gấp
+// đôi (≈12pt) và tràn cả trên lẫn dưới khung. Nhãn đặt theo khung đó nên bị
+// đường vẽ CẮT NGANG (phân số, căn bậc hai, chỉ số trên/dưới…). Đo lại bằng
+// top-edge/bottom-edge: "bounds" cho ra đúng biên nét chữ.
+// PHẢI gọi trong `context`. Số chữ thường (không tràn) cho kết quả nhỏ hơn
+// hoặc bằng khung, nên nơi dùng luôn lấy phần TRÀN = max(0, biên − khung):
+// chữ thường bù 0 ⇒ mọi hình cũ giữ nguyên bố cục.
+#let co-net(nd) = measure({
+ set text(top-edge: "bounds", bottom-edge: "bounds")
+ nd
+})
+
+// Phần nét chữ tràn ra NGOÀI khung, chia đều hai bên: (dx, dy) tính bằng length.
+#let _tran-net(nd) = {
+ let k = measure(nd)
+ let b = co-net(nd)
+ (
+ calc.max(0pt, b.width - k.width) / 2,
+ calc.max(0pt, b.height - k.height) / 2,
+ )
+}
+
+// Nhãn văn bản đặt cạnh điểm P.
+// huong: "above", "below", "left", "right", "above-left", "above-right",
+// "below-left", "below-right", "center"
+// (tên tiếng Việt cũ "tren", "duoi", "trai", "phai", ... vẫn dùng được)
+// quay: góc xoay chữ (vd 90deg, -45deg, hoặc goc-truc(...)). Mặc định 0deg.
+/// Đặt chữ/công thức tại điểm P. `huong` là phía đặt chữ so với P
+/// ("above", "below", "left", "right", "above-left"… hoặc tuple (dx, dy)),
+/// `cach` là khoảng cách tới P, `quay` để xoay chữ theo đường.
+#let nhan(ctx, P, noi-dung, huong: "above", cach: 6pt, mau: black, quay: 0deg) = {
+ let p = toa(ctx, P)
+ // huong: tên hướng, HOẶC vectơ (dx, dy) trên trang (y hướng XUỐNG) — dùng
+ // cho nhãn đặt vuông góc với một đoạn bất kỳ (xem _phap-tuyen).
+ let hd = if type(huong) == array { huong } else {
+ (
+ above: (0, -1), below: (0, 1), left: (-1, 0), right: (1, 0),
+ above-left: (-0.7, -0.7), above-right: (0.7, -0.7),
+ below-left: (-0.7, 0.7), below-right: (0.7, 0.7),
+ center: (0, 0),
+ // bí danh tiếng Việt (tương thích ngược)
+ tren: (0, -1), duoi: (0, 1), trai: (-1, 0), phai: (1, 0),
+ tren-trai: (-0.7, -0.7), tren-phai: (0.7, -0.7),
+ duoi-trai: (-0.7, 0.7), duoi-phai: (0.7, 0.7),
+ giua: (0, 0),
+ ).at(huong)
+ }
+ context {
+ let nd = text(fill: mau, noi-dung)
+ let nd2 = if quay == 0deg { nd } else { rotate(quay, reflow: true, nd) }
+ let s = measure(nd2)
+ // bù phần nét chữ tràn ra ngoài khung (phân số, căn…) — xem `co-net`
+ let (bx, by) = _tran-net(nd2)
+ place(
+ dx: p.at(0) - s.width / 2 + hd.at(0) * (cach + s.width / 2 + bx),
+ dy: p.at(1) - s.height / 2 + hd.at(1) * (cach + s.height / 2 + by),
+ nd2,
+ )
+ }
+}
+
+// Đoạn thẳng AB, kèm NHÃN CHÚ THÍCH đặt trên đoạn (tuỳ chọn):
+// ten : nội dung nhãn (vd $a$, [3 cm]) — none = không ghi
+// tai : vị trí nhãn theo TỈ LỆ từ A đến B (0 = tại A, 1 = tại B, .5 = giữa)
+// huong : auto = tự đặt VUÔNG GÓC phía trên đoạn (đoạn dọc thì ra trái);
+// hoặc tên hướng ("above", "below", "left", ...), hoặc vectơ (dx, dy)
+// cach : khoảng cách từ đoạn tới nhãn
+// ten-quay: true = chữ NẰM DỌC theo đoạn (tự xoay, luôn đọc xuôi)
+// mau-ten : màu chữ (auto = theo màu đoạn)
+// Ví dụ: doan(A, B, ten: $2a$) // giữa đoạn, phía trên
+// doan(A, B, ten: $h$, tai: 0.7, huong: "right", cach: 4pt)
+// doan(A, B, ten: [đường chéo], ten-quay: true)
+/// Đoạn thẳng A–B. Kèm nhãn giữa đoạn bằng `ten:` (đặt chỗ khác bằng `tai:`
+/// tỉ lệ 0..1, lệch sang bên bằng `huong:`, xoay theo đoạn bằng `ten-quay: true`).
+#let doan(
+ ctx, A, B,
+ mau: black, day: 1pt, dut: false,
+ ten: none, tai: 0.5, huong: auto, cach: 6pt, ten-quay: false, mau-ten: auto,
+) = {
+ place(line(start: toa(ctx, A), end: toa(ctx, B), stroke: net(mau, day, dut)))
+ if ten != none {
+ let P = (A.at(0) + tai * (B.at(0) - A.at(0)), A.at(1) + tai * (B.at(1) - A.at(1)))
+ // chữ dọc theo đoạn: chọn chiều A->B hay B->A để không bị lộn ngược
+ let xuoi = toa-pt(ctx, A).at(0) <= toa-pt(ctx, B).at(0)
+ nhan(
+ ctx, P, ten,
+ huong: if huong == auto { _phap-tuyen(ctx, A, B) } else { huong },
+ cach: cach,
+ mau: if mau-ten == auto { mau } else { mau-ten },
+ quay: if ten-quay { if xuoi { goc-truc(ctx, A, B) } else { goc-truc(ctx, B, A) } } else { 0deg },
+ )
+ }
+}
+
+// Điểm (chấm tròn) + nhãn tuỳ chọn.
+// cach: khoảng cách nhãn tới điểm · mau-ten: màu chữ (auto = màu chấm).
+/// Chấm một điểm, kèm tên: `diem(A, ten: $A$, huong: "below-left")`.
+/// Nhiều điểm một lệnh thì dùng `cac-diem`.
+#let diem(ctx, P, ten: none, huong: "tren", bk: 2pt, mau: black, cach: 6pt, mau-ten: black) = {
+ let p = toa(ctx, P)
+ place(dx: p.at(0) - bk, dy: p.at(1) - bk, circle(radius: bk, fill: mau, stroke: none))
+ if ten != none {
+ nhan(ctx, P, ten, huong: huong, cach: cach, mau: if mau-ten == auto { mau } else { mau-ten })
+ }
+}
+
+// Đặt nhãn cho nhiều điểm, VỊ TRÍ nhãn xác định bằng GÓC LƯỢNG GIÁC (thay cho
+// tên hướng "above"/"below"…). Mỗi mục là một tuple:
+// (P, nội-dung, goc) — bán kính & màu lấy mặc định
+// (P, nội-dung, goc, ban-kinh) — ban-kinh là ĐỘ DÀI trang (vd 8pt)
+// (P, nội-dung, goc, ban-kinh, mau) — kèm màu riêng
+// `goc` là góc lượng giác (số trần = ĐỘ, dương = ngược kim đồng hồ; nhãn đặt
+// về phía góc đó so với điểm). Phần tử sau `goc` nhận diện theo kiểu: color ->
+// màu, còn lại -> bán kính, nên thứ tự ban-kinh/mau linh hoạt.
+// nhan-goc((A, $A$, 210), (B, $B$, -30, 9pt), (C, $C$, 90, 8pt, red))
+/// Đặt nhãn nhiều điểm theo GÓC LƯỢNG GIÁC. Mỗi mục:
+/// `(P, nội-dung, goc[, ban-kinh][, mau])` — `goc` số trần = độ, dương ngược
+/// kim đồng hồ; `ban-kinh` là độ dài trang (khoảng cách điểm→nhãn).
+#let nhan-goc(ctx, ..muc, mau: black, ban-kinh: 6pt) = {
+ for m in muc.pos() {
+ let P = m.at(0)
+ let nd = m.at(1)
+ let goc = m.at(2)
+ let r = ban-kinh
+ let c = mau
+ for x in m.slice(3) {
+ if type(x) == color { c = x } else { r = x }
+ }
+ let g = if type(goc) == angle { goc } else { goc * 1deg }
+ // hướng trên TRANG (y hướng XUỐNG) theo góc lượng giác (y toán hướng lên)
+ let hd = (calc.cos(g), -calc.sin(g))
+ nhan(ctx, P, nd, huong: hd, cach: r, mau: c)
+ }
+}
+
+// Mũi tên A -> B.
+#let mui-ten(ctx, A, B, mau: black, day: 1pt, kich: 7pt, dut: false) = {
+ doan(ctx, A, B, mau: mau, day: day, dut: dut)
+ dau-mui-ten(ctx, A, B, mau: mau, kich: kich)
+}
+
+// Vectơ A -> B kèm tên đặt ở trung điểm.
+// dut: true = thân nét đứt (vectơ nằm trên cạnh khuất, kiểu hình SGK);
+// đầu mũi tên luôn nét liền.
+#let vecto(ctx, A, B, ten: none, huong: "tren", mau: black, day: 1.1pt, dut: false) = {
+ mui-ten(ctx, A, B, mau: mau, day: day, dut: dut)
+ if ten != none {
+ nhan(ctx, ((A.at(0) + B.at(0)) / 2, (A.at(1) + B.at(1)) / 2), ten, huong: huong, mau: mau)
+ }
+}
+
+// ---------- Mũi tên HAI ĐẦU (đường ghi số đo) ----------
+// Mũi tên có đầu ở CẢ HAI phía, kiểu đường ghi kích thước trong bản vẽ:
+// thường dùng để chú thích số đo đặt Ở GIỮA đoạn mũi tên.
+// ten : nội dung chú thích (vd $2a$, [5 cm]) — none = chỉ vẽ mũi tên
+// trong : chữ nằm CHÍNH GIỮA thân, thân bị cắt chừa chỗ (|<-- 5 cm -->|)
+// auto = tự bật khi có `ten` và đoạn đủ dài để chứa chữ,
+// không đủ dài thì tự đặt chữ ra ngoài (phía trên đoạn)
+// huong : chỗ đặt chữ khi KHÔNG nằm giữa thân
+// (auto = vuông góc phía trên đoạn, như `doan`)
+// ten-quay : true = chữ NẰM DỌC theo mũi tên (luôn đọc xuôi)
+// vach : true = kẻ vạch chặn vuông góc ở hai đầu (kiểu ghi kích thước)
+// le : lùi hai đầu mũi tên vào trong, để hở khỏi vật đang đo
+// nen : màu nền lót sau chữ khi chữ nằm giữa thân (none = không lót)
+// Ví dụ:
+// mui-ten-2-dau(A, B, ten: [5 cm]) // chữ giữa thân
+// mui-ten-2-dau(A, B, ten: $2a$, trong: false) // chữ trên đoạn
+// mui-ten-2-dau((0, -0.4), (4, -0.4), ten: $h$, vach: true)
+/// Mũi tên HAI ĐẦU giữa A và B, kiểu đường ghi kích thước. `ten:` là số đo
+/// đặt GIỮA thân (`trong: false` để đưa chữ ra ngoài), `vach: true` kẻ vạch
+/// chặn hai đầu, `le:` lùi hai đầu vào trong cho hở khỏi vật đang đo.
+#let mui-ten-2-dau(
+ ctx, A, B,
+ mau: black, day: 1pt, kich: 7pt, dut: false,
+ ten: none, huong: auto, cach: 5pt, ten-quay: false, mau-ten: auto,
+ trong: auto, dem: 3pt, nen: white,
+ vach: false, dai-vach: 9pt,
+ le: 0pt,
+) = context {
+ let a0 = toa-pt(ctx, A)
+ let b0 = toa-pt(ctx, B)
+ let (dx, dy) = (b0.at(0) - a0.at(0), b0.at(1) - a0.at(1))
+ let l0 = calc.sqrt(dx * dx + dy * dy)
+ if l0 < 0.001 { return }
+ let (ux, uy) = (dx / l0, dy / l0)
+ let lp = calc.min(le.pt(), l0 / 2 - 0.5)
+ let a = (a0.at(0) + lp * ux, a0.at(1) + lp * uy)
+ let b = (b0.at(0) - lp * ux, b0.at(1) - lp * uy)
+ let l = l0 - 2 * lp
+ let mc = ((a.at(0) + b.at(0)) / 2, (a.at(1) + b.at(1)) / 2)
+ let mt = ((A.at(0) + B.at(0)) / 2, (A.at(1) + B.at(1)) / 2)
+ let mau-t = if mau-ten == auto { mau } else { mau-ten }
+
+ // Kích thước chữ (đo cả biên nét để phân số/căn không bị thân mũi tên cắt).
+ let nd = if ten == none { none } else {
+ let n = text(fill: mau-t, ten)
+ let q = if ten-quay {
+ let g = if a.at(0) <= b.at(0) { goc-truc(ctx, A, B) } else { goc-truc(ctx, B, A) }
+ rotate(g, reflow: true, n)
+ } else { n }
+ q
+ }
+ let (kw, kh, bw, bh) = if nd == none { (0pt, 0pt, 0pt, 0pt) } else {
+ let k = measure(nd)
+ let bn = co-net(nd)
+ (k.width, k.height, calc.max(k.width, bn.width), calc.max(k.height, bn.height))
+ }
+ // Bề ngang chỗ chữ chiếm DỌC THEO thân mũi tên + hai đầu mũi tên phải đủ chỗ.
+ let ho = bw.pt() * calc.abs(ux) + bh.pt() * calc.abs(uy) + 2 * dem.pt()
+ let o-giua = (
+ ten != none
+ and (if trong == auto { l > ho + 3.2 * kich.pt() } else { trong })
+ )
+
+ // Thân: cắt đôi chừa chỗ cho chữ nếu chữ nằm giữa
+ if o-giua {
+ let h2 = calc.min(ho / 2, l / 2 - 0.2)
+ doan-pt(a, (mc.at(0) - h2 * ux, mc.at(1) - h2 * uy), mau: mau, day: day, dut: dut)
+ doan-pt((mc.at(0) + h2 * ux, mc.at(1) + h2 * uy), b, mau: mau, day: day, dut: dut)
+ } else {
+ doan-pt(a, b, mau: mau, day: day, dut: dut)
+ }
+ _dau-pt(b, a, mau: mau, kich: kich)
+ _dau-pt(a, b, mau: mau, kich: kich)
+
+ // Vạch chặn hai đầu (kiểu đường ghi kích thước)
+ if vach {
+ let hv = dai-vach.pt() / 2
+ let (px, py) = (-uy, ux)
+ for q in (a0, b0) {
+ doan-pt(
+ (q.at(0) - hv * px, q.at(1) - hv * py),
+ (q.at(0) + hv * px, q.at(1) + hv * py),
+ mau: mau, day: day,
+ )
+ }
+ }
+
+ // Chú thích
+ if ten != none {
+ if o-giua {
+ if nen != none {
+ place(
+ dx: (mc.at(0) - bw.pt() / 2 - dem.pt() / 2) * 1pt,
+ dy: (mc.at(1) - bh.pt() / 2) * 1pt,
+ rect(width: bw + dem, height: bh, fill: nen, stroke: none),
+ )
+ }
+ place(dx: (mc.at(0) - kw.pt() / 2) * 1pt, dy: (mc.at(1) - kh.pt() / 2) * 1pt, nd)
+ } else {
+ nhan(
+ ctx, mt, ten,
+ huong: if huong == auto { _phap-tuyen(ctx, A, B) } else { huong },
+ cach: cach, mau: mau-t,
+ quay: if ten-quay {
+ if a.at(0) <= b.at(0) { goc-truc(ctx, A, B) } else { goc-truc(ctx, B, A) }
+ } else { 0deg },
+ )
+ }
+ }
+}
+
+// Đường gấp khúc qua dãy điểm. dut: nét đứt (vẽ cách đoạn -> đều trên đường cong).
+#let duong-cong(ctx, cac-diem, mau: black, day: 1pt, dut: false, dong: false) = {
+ let pts = cac-diem
+ if dong and pts.len() > 1 { pts = pts + (pts.at(0),) }
+ for i in range(pts.len() - 1) {
+ if not dut or calc.rem(i, 2) == 0 {
+ doan(ctx, pts.at(i), pts.at(i + 1), mau: mau, day: day)
+ }
+ }
+}
+
+// Đường gấp khúc nối lần lượt A - B - C - ... KHÁC duong-cong ở chỗ:
+// dut: true là nét đứt THẬT trên TỪNG đoạn (hợp polyline ít đoạn;
+// duong-cong đứt kiểu "bỏ đoạn xen kẽ" chỉ hợp đường nhiều mẫu).
+// dong: true nối điểm cuối về điểm đầu.
+#let duong-gap-khuc(ctx, cac-diem, mau: black, day: 1pt, dut: false, dong: false) = {
+ let pts = cac-diem
+ if dong and pts.len() > 1 { pts = pts + (pts.at(0),) }
+ for i in range(pts.len() - 1) {
+ doan(ctx, pts.at(i), pts.at(i + 1), mau: mau, day: day, dut: dut)
+ }
+}
+
+// Đa giác kín: viền + tô màu tuỳ chọn (to: màu hoặc none).
+/// Đa giác khép kín. Đỉnh truyền vào dưới dạng MỘT MẢNG:
+/// `da-giac((A, B, C))` — không phải `da-giac(A, B, C)`. `to:` để tô màu.
+#let da-giac(ctx, cac-diem, mau: black, day: 1pt, dut: false, to: none) = {
+ if to != none {
+ da-giac-pt(cac-diem.map(P => toa-pt(ctx, P)), to: to)
+ }
+ duong-cong(ctx, cac-diem, mau: mau, day: day, dut: dut, dong: true)
+}
+
+// ---------- cac-doan: VẼ NHIỀU NÉT TRONG MỘT LỆNH (kiểu \draw của TikZ) ----
+// Đối số vị trí có thể trộn thoải mái:
+// • các ĐIỂM liền nhau -> nối thành MỘT đường gấp khúc:
+// cac-doan(A, B) // một đoạn
+// cac-doan(A, B, C, D) // A-B-C-D
+// • một MẢNG ĐIỂM -> một nét riêng:
+// cac-doan((A, B), (C, D)) // hai đoạn rời
+// • duong(...) -> một nét riêng CÓ KIỂU RIÊNG (đè lên kiểu chung):
+// cac-doan(
+// duong(A, B, C, dong: true, to: blue.lighten(85%)), // tam giác tô
+// duong(S, H, dut: true, ten: $h$), // nét đứt có nhãn
+// (M, N), // đoạn thường
+// mau: blue, day: 1.2pt, // kiểu CHUNG
+// )
+// Tuỳ chọn mỗi nét (đặt ở duong(...) hoặc dùng chung ở cac-doan):
+// mau · day · dut · dong (khép kín) · to (tô màu, tự khép kín)
+// mui-ten (vẽ đầu mũi tên ở điểm cuối) · ten/tai/huong/cach/ten-quay/mau-ten
+// (nhãn đặt tại tỉ lệ `tai` tính theo TỔNG chiều dài nét — xem `doan`)
+#let duong(
+ ..noi-dung,
+ mau: auto, day: auto, dut: auto, dong: auto, to: auto, mui-ten: auto,
+ ten: auto, tai: auto, huong: auto, cach: auto, ten-quay: auto, mau-ten: auto,
+) = {
+ let p = noi-dung.pos()
+ // duong((A, B, C)) — truyền nguyên một mảng điểm
+ if (p.len() == 1 and type(p.at(0)) == array and p.at(0).len() > 0
+ and type(p.at(0).at(0)) == array) { p = p.at(0) }
+ (
+ bg-duong: true, diem: p,
+ mau: mau, day: day, dut: dut, dong: dong, to: to, mui-ten: mui-ten,
+ ten: ten, tai: tai, huong: huong, cach: cach, ten-quay: ten-quay, mau-ten: mau-ten,
+ )
+}
+
+#let _la-duong(x) = type(x) == dictionary and x.at("bg-duong", default: false)
+#let _la-diem(x) = type(x) == array and x.len() == 2 and type(x.at(0)) != array
+
+/// Vẽ NHIỀU nét trong một lệnh: điểm liền nhau thành một đường gấp khúc,
+/// mỗi MẢNG điểm là một nét riêng — `cac-doan((A, B), (C, D), (M, N))`.
+/// Nét cần kiểu riêng thì bọc `duong(...)`.
+#let cac-doan(
+ ctx, ..noi-dung,
+ mau: black, day: 1pt, dut: false, dong: false, to: none, mui-ten: false,
+ ten: none, tai: 0.5, huong: auto, cach: 6pt, ten-quay: false, mau-ten: auto,
+) = {
+ // gom đối số vị trí thành danh sách các nét
+ let nets = ()
+ let dem = ()
+ for x in noi-dung.pos() {
+ if _la-diem(x) { dem.push(x) } else {
+ if dem.len() > 0 { nets.push(duong(..dem)); dem = () }
+ if _la-duong(x) { nets.push(x) } else if type(x) == array { nets.push(duong(x)) }
+ }
+ }
+ if dem.len() > 0 { nets.push(duong(..dem)) }
+ // vẽ từng nét: giá trị auto của nét lấy theo tuỳ chọn chung
+ for n in nets {
+ let ly(khoa, chung) = if n.at(khoa) == auto { chung } else { n.at(khoa) }
+ let (m, d, du) = (ly("mau", mau), ly("day", day), ly("dut", dut))
+ let (dg, tt) = (ly("dong", dong), ly("to", to))
+ let pts = n.diem
+ if pts.len() == 0 { continue }
+ if pts.len() == 1 { diem(ctx, pts.at(0), mau: m); continue }
+ if tt != none { da-giac-pt(pts.map(P => toa-pt(ctx, P)), to: tt) }
+ let cd = if dg or tt != none { pts + (pts.at(0),) } else { pts }
+ for i in range(cd.len() - 1) {
+ doan(ctx, cd.at(i), cd.at(i + 1), mau: m, day: d, dut: du)
+ }
+ if ly("mui-ten", mui-ten) {
+ dau-mui-ten(ctx, cd.at(cd.len() - 2), cd.at(cd.len() - 1), mau: m)
+ }
+ // nhãn: đặt tại tỉ lệ `tai` tính theo TỔNG chiều dài nét (trên trang)
+ let nh = ly("ten", ten)
+ if nh != none {
+ let t = ly("tai", tai)
+ let dai = range(cd.len() - 1).map(i => {
+ let a = toa-pt(ctx, cd.at(i))
+ let b = toa-pt(ctx, cd.at(i + 1))
+ calc.sqrt(calc.pow(b.at(0) - a.at(0), 2) + calc.pow(b.at(1) - a.at(1), 2))
+ })
+ let tong = dai.sum()
+ let moc = calc.max(calc.min(t, 1), 0) * tong
+ let i = 0
+ let con = moc
+ while i < dai.len() - 1 and con > dai.at(i) { con = con - dai.at(i); i = i + 1 }
+ let ti = if dai.at(i) > 0.0001 { con / dai.at(i) } else { 0.5 }
+ let (U, V) = (cd.at(i), cd.at(i + 1))
+ let P = (U.at(0) + ti * (V.at(0) - U.at(0)), U.at(1) + ti * (V.at(1) - U.at(1)))
+ let hg = ly("huong", huong)
+ let mt = ly("mau-ten", mau-ten)
+ let xuoi = toa-pt(ctx, U).at(0) <= toa-pt(ctx, V).at(0)
+ nhan(
+ ctx, P, nh,
+ huong: if hg == auto { _phap-tuyen(ctx, U, V) } else { hg },
+ cach: ly("cach", cach),
+ mau: if mt == auto { m } else { mt },
+ quay: if ly("ten-quay", ten-quay) {
+ if xuoi { goc-truc(ctx, U, V) } else { goc-truc(ctx, V, U) }
+ } else { 0deg },
+ )
+ }
+ }
+}
+
+// ---------- cac-diem: CHẤM + ĐẶT TÊN NHIỀU ĐIỂM TRONG MỘT LỆNH ----------
+// Mỗi đối số vị trí là một điểm, viết theo 1 trong 4 dạng:
+// A -> chỉ chấm, không nhãn
+// (A, $A$) -> chấm + nhãn, hướng lấy theo `huong` chung
+// (A, $A$, "left") -> + hướng riêng
+// (A, $A$, "left", red) -> + màu riêng (cả chấm lẫn chữ nếu mau-ten: auto)
+// Tuỳ chọn chung: mau · bk (bán kính chấm) · huong · cach (nhãn cách chấm)
+// · mau-ten (auto = theo màu chấm).
+// cac-diem(
+// (A, $A$, "below-left"), (B, $B$, "below-right"), (C, $C$, "above"),
+// mau: blue, bk: 2.2pt,
+// )
+/// Chấm + đặt tên nhiều điểm một lệnh. Mỗi mục: `A` | `(A, $A$)` |
+/// `(A, $A$, "left")` | `(A, $A$, "left", red)`.
+#let cac-diem(
+ ctx, ..noi-dung,
+ mau: black, bk: 2pt, huong: "above", cach: 6pt, mau-ten: black,
+) = {
+ for x in noi-dung.pos() {
+ let la-diem = type(x) == array and x.len() == 2 and type(x.at(0)) != array
+ let P = if la-diem { x } else { x.at(0) }
+ let t = if la-diem { none } else { x.at(1, default: none) }
+ let h = if la-diem { auto } else { x.at(2, default: auto) }
+ let m = if la-diem { auto } else { x.at(3, default: auto) }
+ let mc = if m == auto { mau } else { m }
+ diem(
+ ctx, P, ten: t,
+ huong: if h == auto { huong } else { h },
+ bk: bk, mau: mc, cach: cach,
+ mau-ten: if mau-ten == auto or m != auto { mc } else { mau-ten },
+ )
+ }
+}
+
+// Dãy điểm trên cung elip tâm O bán trục (a, b), góc tu -> den.
+#let diem-cung(O, a, b, tu, den, n: 48) = range(n + 1).map(i => {
+ let t = tu + (den - tu) * (i / n)
+ (O.at(0) + a * calc.cos(t), O.at(1) + b * calc.sin(t))
+})
+
+// Đường tròn tâm O bán kính r (theo đơn vị trục x; nếu 2 trục cùng tỉ lệ
+// thì là đường tròn thật, khác tỉ lệ sẽ thành elip tương ứng).
+#let duong-tron(ctx, O, r, mau: black, day: 1pt, dut: false, to: none) = {
+ let c = toa(ctx, O)
+ let rx = r * ctx.sx
+ let ry = r * ctx.sy
+ place(
+ dx: c.at(0) - rx, dy: c.at(1) - ry,
+ ellipse(width: 2 * rx, height: 2 * ry, stroke: net(mau, day, dut), fill: to),
+ )
+}
+
+// Elip tâm O, bán trục a (ngang), b (dọc); quay: góc xoay quanh tâm
+// (dương = ngược chiều kim đồng hồ), n: số mẫu khi vẽ elip xoay.
+#let elip(ctx, O, a, b, mau: black, day: 1pt, dut: false, to: none, quay: 0deg, n: 72) = {
+ if quay == 0deg and not ("bien-doi" in ctx and ctx.bien-doi != none) {
+ let c = toa(ctx, O)
+ let rx = a * ctx.sx
+ let ry = b * ctx.sy
+ place(
+ dx: c.at(0) - rx, dy: c.at(1) - ry,
+ ellipse(width: 2 * rx, height: 2 * ry, stroke: net(mau, day, dut), fill: to),
+ )
+ } else {
+ // elip xoay (hoặc ctx có phép biến hình): vẽ theo dãy điểm quanh chu vi
+ let pts = diem-cung(O, a, b, 0deg, 360deg, n: n).map(P => quay-diem(P, O, quay))
+ if to != none { da-giac-pt(pts.map(P => toa-pt(ctx, P)), to: to) }
+ duong-cong(ctx, pts, mau: mau, day: day, dut: dut, dong: true)
+ }
+}
+
+// Cung tròn tâm O bán kính r từ góc `tu` đến `den` (kiểu angle, vd 30deg).
+#let cung(ctx, O, r, tu: 0deg, den: 180deg, mau: black, day: 1pt, dut: false, n: 48, quay: 0deg) = {
+ duong-cong(ctx, diem-cung(O, r, r, tu + quay, den + quay, n: n), mau: mau, day: day, dut: dut)
+}
+
+// Cung elip (dùng cho hình không gian: đáy nón, trụ...).
+#let cung-elip(ctx, O, a, b, tu: 0deg, den: 180deg, mau: black, day: 1pt, dut: false, n: 48, quay: 0deg) = {
+ let pts = diem-cung(O, a, b, tu, den, n: n)
+ if quay != 0deg { pts = pts.map(P => quay-diem(P, O, quay)) }
+ duong-cong(ctx, pts, mau: mau, day: day, dut: dut)
+}
+
+// ---------- Đường xoắn ốc ----------
+// Xoắn ốc Archimedes tâm O: bán kính TĂNG ĐỀU theo góc, quét từ `tu` đến `den`.
+// Góc quét |den - tu| ĐƯỢC PHÉP lớn hơn 360° (nhiều vòng);
+// den > tu = ngược chiều kim đồng hồ (chiều dương), den < tu = cùng chiều kim đồng hồ.
+// r : bán kính tại góc `tu`
+// r-cuoi : bán kính tại góc `den` (auto = r + buoc · số vòng quét)
+// buoc : khoảng cách giữa hai vòng liên tiếp (dùng khi r-cuoi: auto)
+// mui-ten : true|"cuoi" (đầu mũi tên ở điểm cuối) · "dau" · "ca-hai" · false
+// ten/huong/cach/mau-ten : nhãn đặt cạnh điểm cuối của xoắn ốc
+#let xoan-oc(
+ ctx, O,
+ tu: 0deg, den: 360deg,
+ r: 0.12, r-cuoi: auto, buoc: 0.16,
+ mau: black, day: 1pt, dut: false,
+ mui-ten: true, kich: 7pt,
+ n: auto,
+ ten: none, huong: "above", cach: 6pt, mau-ten: auto,
+) = {
+ let quet = den - tu
+ let so-vong = calc.abs(quet.deg()) / 360
+ let r2 = if r-cuoi == auto { r + buoc * so-vong } else { r-cuoi }
+ let m = if n == auto { calc.max(32, int(calc.ceil(so-vong * 72))) } else { n }
+ let pts = range(m + 1).map(i => {
+ let s = i / m
+ let a = tu + quet * s
+ let rr = r + (r2 - r) * s
+ (O.at(0) + rr * calc.cos(a), O.at(1) + rr * calc.sin(a))
+ })
+ duong-cong(ctx, pts, mau: mau, day: day, dut: dut)
+ if mui-ten == true or mui-ten == "cuoi" or mui-ten == "ca-hai" {
+ dau-mui-ten(ctx, pts.at(m - 1), pts.at(m), mau: mau, kich: kich)
+ }
+ if mui-ten == "dau" or mui-ten == "ca-hai" {
+ dau-mui-ten(ctx, pts.at(1), pts.at(0), mau: mau, kich: kich)
+ }
+ if ten != none {
+ nhan(
+ ctx, pts.at(m), ten,
+ huong: huong, cach: cach,
+ mau: if mau-ten == auto { mau } else { mau-ten },
+ )
+ }
+}
+
+// Góc lượng giác (OA, OM): xoắn ốc từ tia OA quay tới tia OM (kiểu SGK 11).
+// chieu : "duong" = ngược chiều kim đồng hồ (mặc định) · "am" = cùng chiều
+// vong : số vòng quay THÊM (0, 1, 2, ...) trước khi dừng ở tia OM
+// so-do : true = tự ghi số đo (vd -430°) cạnh mũi tên; `ten` được ưu tiên
+// Ví dụ hình "AOM = 70°, (OA, OM) = -430°": goc-luong-giac(O, A, M, chieu: "am", vong: 1)
+#let goc-luong-giac(
+ ctx, O, A, M,
+ chieu: "duong", vong: 0,
+ r: auto, r-cuoi: auto, buoc: auto,
+ mau: blue, day: 0.9pt, dut: false, kich: 6pt,
+ ten: none, so-do: false, huong: "above", cach: 6pt, mau-ten: auto,
+ n: auto,
+) = {
+ let am = chieu in ("am", "-", -1) or chieu == false
+ // r, buoc mặc định tính THEO chiều dài tia (để hình nào cũng cân đối)
+ let dai = calc.min(
+ calc.sqrt(calc.pow(A.at(0) - O.at(0), 2) + calc.pow(A.at(1) - O.at(1), 2)),
+ calc.sqrt(calc.pow(M.at(0) - O.at(0), 2) + calc.pow(M.at(1) - O.at(1), 2)),
+ )
+ if dai <= 0 { dai = 1 }
+ let r = if r == auto { 0.12 * dai } else { r }
+ let buoc = if buoc == auto { 0.14 * dai } else { buoc }
+ let a1 = calc.atan2(A.at(0) - O.at(0), A.at(1) - O.at(1))
+ let a2 = calc.atan2(M.at(0) - O.at(0), M.at(1) - O.at(1))
+ let d = a2 - a1
+ while d < 0deg { d = d + 360deg }
+ while d >= 360deg { d = d - 360deg }
+ if am and d != 0deg { d = d - 360deg }
+ let tong = d + (if am { -1 } else { 1 }) * vong * 360deg
+ let nd = if ten != none { ten } else if so-do {
+ let v = calc.round(tong.deg(), digits: 1)
+ let s = if calc.abs(v - calc.round(v)) < 0.01 { str(int(calc.round(v))) } else { str(v) }
+ [#s#sym.degree]
+ } else { none }
+ xoan-oc(
+ ctx, O, tu: a1, den: a1 + tong,
+ r: r, r-cuoi: r-cuoi, buoc: buoc,
+ mau: mau, day: day, dut: dut, kich: kich, n: n,
+ ten: nd, huong: huong, cach: cach, mau-ten: mau-ten,
+ )
+}
+
+// ---------- Đường cong uốn lượn (Bézier kiểu \draw ... controls ... của TikZ) ----------
+// `dieu-khien(c1, c2)` — điểm điều khiển của MỘT đoạn cong, đặt XEN GIỮA hai
+// điểm neo (giống `.. controls (c1) and (c2) ..` của TikZ):
+// duong-luon(A, dieu-khien(c1, c2), B, dieu-khien(c3, c4), C)
+// dieu-khien(c1) // một điểm điều khiển (c2 = c1)
+// dieu-khien(c1, auto) // c1 tự chọn cho mềm, c2 do người dùng đặt (auto lấy Catmull-Rom)
+// Đoạn KHÔNG có dieu-khien giữa hai neo -> tự làm MỀM bằng Catmull-Rom
+// (đường trơn đi qua đúng các điểm neo, khỏi tính điểm điều khiển bằng tay).
+#let dieu-khien(..a) = {
+ let p = a.pos()
+ let c1 = p.at(0, default: auto)
+ let c2 = p.at(1, default: c1)
+ (bg-dk: true, c1: c1, c2: c2)
+}
+#let _la-dk(x) = type(x) == dictionary and x.at("bg-dk", default: false)
+
+// Lấy MẢNG điểm mẫu của đường uốn lượn (dùng chung cho vẽ và cho nhãn bám đường).
+// items: dãy đối số vị trí (điểm neo trộn với dieu-khien(...)); n: mẫu mỗi đoạn.
+#let _luon-mau(items, n, dong) = {
+ let anchors = ()
+ let ctrls = () // ctrls.at(k) = dieu-khien của đoạn anchors[k] -> anchors[k+1]
+ let pending = none
+ for x in items {
+ if _la-dk(x) { pending = x }
+ else if _la-diem(x) {
+ if anchors.len() > 0 { ctrls.push(pending); pending = none }
+ anchors.push(x)
+ }
+ }
+ if anchors.len() < 2 { return anchors }
+ if dong {
+ ctrls.push(pending) // điều khiển của đoạn khép kín (cuối -> đầu)
+ anchors.push(anchors.at(0))
+ }
+ let m = anchors.len()
+ let getc = i => anchors.at(calc.max(0, calc.min(m - 1, i)))
+ let getw = i => {
+ if dong {
+ let base = m - 1
+ anchors.at(calc.rem(calc.rem(i, base) + base, base))
+ } else { getc(i) }
+ }
+ let pts = (anchors.at(0),)
+ for k in range(m - 1) {
+ let p1 = anchors.at(k)
+ let p2 = anchors.at(k + 1)
+ let p0 = getw(k - 1)
+ let p3 = getw(k + 2)
+ // điểm điều khiển Catmull-Rom (đường trơn qua các neo)
+ let ec1 = (p1.at(0) + (p2.at(0) - p0.at(0)) / 6, p1.at(1) + (p2.at(1) - p0.at(1)) / 6)
+ let ec2 = (p2.at(0) - (p3.at(0) - p1.at(0)) / 6, p2.at(1) - (p3.at(1) - p1.at(1)) / 6)
+ let dk = ctrls.at(k, default: none)
+ let c1 = if dk == none or dk.c1 == auto { ec1 } else { dk.c1 }
+ let c2 = if dk == none or dk.c2 == auto { ec2 } else { dk.c2 }
+ for j in range(1, n + 1) {
+ let t = j / n
+ let u = 1 - t
+ let x = u * u * u * p1.at(0) + 3 * u * u * t * c1.at(0) + 3 * u * t * t * c2.at(0) + t * t * t * p2.at(0)
+ let y = u * u * u * p1.at(1) + 3 * u * u * t * c1.at(1) + 3 * u * t * t * c2.at(1) + t * t * t * p2.at(1)
+ pts.push((x, y))
+ }
+ }
+ pts
+}
+
+// TRẢ VỀ mảng điểm mẫu của đường uốn lượn (dùng cho nhan-cong hoặc để vẽ lại).
+// KHÔNG cần ctx (chỉ tính trên toạ độ toán).
+#let diem-luon(..noi-dung, n: 16, dong: false) = _luon-mau(noi-dung.pos(), n, dong)
+
+// Vẽ đường cong uốn lượn qua các điểm neo, điểm điều khiển kiểu `controls` TikZ.
+// duong-luon(A, B, C, D) // trơn tự động qua 4 neo
+// duong-luon(A, dieu-khien(c1, c2), B) // Bézier bậc ba một đoạn
+// duong-luon(A, dieu-khien(c1,c2), B, C, dong: true, to: blue.lighten(85%))
+// Tuỳ chọn: mau · day · dut · dong (khép kín) · to (tô) · mui-ten · kich ·
+// ten/tai/huong/cach/ten-quay/mau-ten (nhãn đặt theo TỈ LỆ độ dài, như cac-doan) ·
+// n (số mẫu mỗi đoạn, tăng cho mượt).
+#let duong-luon(
+ ctx, ..noi-dung,
+ mau: black, day: 1pt, dut: false, dong: false, to: none,
+ mui-ten: false, kich: 7pt,
+ ten: none, tai: 0.5, huong: auto, cach: 6pt, ten-quay: false, mau-ten: auto,
+ n: 16,
+) = {
+ let pts = _luon-mau(noi-dung.pos(), n, dong)
+ if pts.len() < 2 { return }
+ if to != none { da-giac-pt(pts.map(P => toa-pt(ctx, P)), to: to) }
+ duong-cong(ctx, pts, mau: mau, day: day, dut: dut)
+ if mui-ten {
+ dau-mui-ten(ctx, pts.at(pts.len() - 2), pts.at(pts.len() - 1), mau: mau, kich: kich)
+ }
+ if ten != none {
+ let pg = pts.map(P => toa-pt(ctx, P))
+ let dai = range(pg.len() - 1).map(i => {
+ let a = pg.at(i)
+ let b = pg.at(i + 1)
+ calc.sqrt(calc.pow(b.at(0) - a.at(0), 2) + calc.pow(b.at(1) - a.at(1), 2))
+ })
+ let tong = dai.sum()
+ let moc = calc.max(calc.min(tai, 1), 0) * tong
+ let i = 0
+ let con = moc
+ while i < dai.len() - 1 and con > dai.at(i) { con = con - dai.at(i); i = i + 1 }
+ let ti = if dai.at(i) > 0.0001 { con / dai.at(i) } else { 0.5 }
+ let U = pts.at(i)
+ let V = pts.at(i + 1)
+ let P = (U.at(0) + ti * (V.at(0) - U.at(0)), U.at(1) + ti * (V.at(1) - U.at(1)))
+ let xuoi = toa-pt(ctx, U).at(0) <= toa-pt(ctx, V).at(0)
+ nhan(
+ ctx, P, ten,
+ huong: if huong == auto { _phap-tuyen(ctx, U, V) } else { huong },
+ cach: cach,
+ mau: if mau-ten == auto { mau } else { mau-ten },
+ quay: if ten-quay { if xuoi { goc-truc(ctx, U, V) } else { goc-truc(ctx, V, U) } } else { 0deg },
+ )
+ }
+}
+
+// ---------- Nhãn chữ BÁM THEO đường cong ----------
+// Chuỗi văn bản từ nội dung (str hoặc content đơn giản).
+#let _chuoi-cong(c) = {
+ if type(c) == str { c }
+ else if type(c) == content {
+ if c.has("text") { c.text }
+ else if c.has("children") { c.children.map(_chuoi-cong).fold("", (a, b) => a + b) }
+ else if c.has("body") { _chuoi-cong(c.body) }
+ else { "" }
+ } else { str(c) }
+}
+
+// Đặt từng ký tự của `chu` DỌC THEO đường `duong` (mảng điểm toạ độ toán, vd
+// lấy từ diem-luon / diem-cung / lay-mau), tự xoay tiếp tuyến, canh theo độ dài cung.
+// duong: mảng điểm (>= 2). chu: str (khuyên dùng) hoặc content văn bản thuần.
+// tu : vị trí BẮT ĐẦU theo tỉ lệ độ dài đường (0 = đầu, 1 = cuối, .5 = giữa)
+// can : "trai" (bắt đầu tại `tu`) · "giua" (canh giữa chữ quanh `tu`) · "phai"
+// khoang: giãn cách thêm giữa các ký tự (length)
+// co : cỡ chữ (auto = theo cỡ hiện hành) · mau : màu chữ
+// phia : "tren" (chữ nằm TRÊN đường) · "duoi" · "giua" (tâm chữ trên đường)
+// cach : khoảng hở giữa chữ và đường (khi phia = tren/duoi)
+// dao : true = đi ngược đường (khi đường vẽ từ phải sang trái, chữ khỏi lộn ngược)
+// Ví dụ: nhan-cong(diem-cung((0,0), 2, 2, 20deg, 160deg), "cung tron")
+#let nhan-cong(
+ ctx, duong, chu,
+ tu: 0, can: "trai", khoang: 0pt,
+ co: auto, mau: black, phia: "tren", cach: 2pt, dao: false,
+) = {
+ let pts = if dao { duong.rev() } else { duong }
+ let txt = _chuoi-cong(chu)
+ let cl = txt.clusters()
+ if pts.len() < 2 or cl.len() == 0 { return }
+ let pg = pts.map(P => toa-pt(ctx, P))
+ let cum = (0.0,)
+ for i in range(pg.len() - 1) {
+ let a = pg.at(i)
+ let b = pg.at(i + 1)
+ cum.push(cum.at(i) + calc.sqrt(calc.pow(b.at(0) - a.at(0), 2) + calc.pow(b.at(1) - a.at(1), 2)))
+ }
+ let tong = cum.at(cum.len() - 1)
+ context {
+ let els = cl.map(g => if co == auto { text(fill: mau, g) } else { text(size: co, fill: mau, g) })
+ let ws = els.map(e => measure(e).width.pt())
+ let totw = ws.sum() + khoang.pt() * calc.max(0, els.len() - 1)
+ let s0 = tu * tong
+ if can == "giua" { s0 = s0 - totw / 2 } else if can == "phai" { s0 = s0 - totw }
+ let cursor = s0
+ for idx in range(els.len()) {
+ let w = ws.at(idx)
+ let mid = calc.max(0, calc.min(tong, cursor + w / 2))
+ let i = 0
+ while i < cum.len() - 2 and cum.at(i + 1) < mid { i = i + 1 }
+ let seg = cum.at(i + 1) - cum.at(i)
+ let t = if seg > 0.000001 { (mid - cum.at(i)) / seg } else { 0 }
+ let A = pg.at(i)
+ let B = pg.at(i + 1)
+ let px = A.at(0) + t * (B.at(0) - A.at(0))
+ let py = A.at(1) + t * (B.at(1) - A.at(1))
+ let dx = B.at(0) - A.at(0)
+ let dy = B.at(1) - A.at(1)
+ let ll = calc.sqrt(dx * dx + dy * dy)
+ let (ux, uy) = if ll > 0.000001 { (dx / ll, dy / ll) } else { (1, 0) }
+ let ang = calc.atan2(dx, dy) // góc của vectơ (dx, dy) trên TRANG (y xuống)
+ let el = els.at(idx)
+ let hh = measure(el).height.pt()
+ let off = if phia == "tren" { cach.pt() + hh / 2 } else if phia == "duoi" { -(cach.pt() + hh / 2) } else { 0 }
+ let cx = px + uy * off // pháp tuyến hướng "lên" trên trang: (uy, -ux)
+ let cy = py + (-ux) * off
+ place(
+ dx: (cx - w / 2) * 1pt,
+ dy: (cy - hh / 2) * 1pt,
+ rotate(ang, reflow: false, el),
+ )
+ cursor = cursor + w + khoang.pt()
+ }
+ }
+}
+
+// ---------- Đánh dấu góc ----------
+// Góc (thường, không vuông) giữa hai tia O->A và O->B:
+// cung nhỏ + nhãn + số cung (1..3) + tô quạt + tự ghi số đo.
+// to : màu tô hình quạt (nên trong suốt, vd rgb(255, 170, 0, 70))
+// so-do : true = tự ghi số đo góc (vd 60°) khi không đặt `ten`
+// vach : 1..3 vạch CẮT NGANG cung (ký hiệu hai góc bằng nhau, kiểu SGK).
+// Khác `so-cung` (vẽ nhiều cung đồng tâm) — `vach` chỉ vẽ MỘT cung
+// rồi gạch 1/2/3 vạch nhỏ vuông góc cung tại giữa cung.
+// Bí danh `vach-danh-dau:` dùng được y hệt.
+/// Đánh dấu góc (không vuông) TẠI O, giữa hai tia O→A và O→B.
+/// ĐỈNH là đối số ĐẦU. Quen lối TikZ (đỉnh ở giữa) thì dùng `ve-goc(A, O, B)`.
+/// `ten:` nhãn · `so-do: true` tự ghi số đo · `vach: 1..3` vạch góc bằng nhau ·
+/// `so-cung: 1..3` cung đồng tâm · `to:` tô quạt · `cach-nhan:` nhãn xa/gần cung.
+#let goc(
+ ctx, O, A, B,
+ r: 0.45, // bán kính cung (đơn vị toạ độ)
+ ten: none,
+ so-do: false, // tự ghi số đo (làm tròn 0.1°); `ten` được ưu tiên
+ so-cung: 1,
+ vach: 0, // số vạch đánh dấu cắt ngang cung (0 = không)
+ vach-danh-dau: auto, // bí danh của `vach`
+ dai-vach: 6pt, // độ dài mỗi vạch
+ to: none, // tô màu hình quạt của góc
+ mau: black, day: 0.8pt,
+ cach-nhan: 1.9, // hệ số đặt nhãn so với r
+) = {
+ let vach = if vach-danh-dau == auto { vach } else { vach-danh-dau }
+ let a1 = calc.atan2(A.at(0) - O.at(0), A.at(1) - O.at(1))
+ let a2 = calc.atan2(B.at(0) - O.at(0), B.at(1) - O.at(1))
+ let d = a2 - a1
+ if d > 180deg { d = d - 360deg }
+ if d < -180deg { d = d + 360deg }
+ if to != none {
+ da-giac-pt(
+ ((O,) + diem-cung(O, r, r, a1, a1 + d, n: 32)).map(P => toa-pt(ctx, P)),
+ to: to,
+ )
+ }
+ for i in range(so-cung) {
+ let rr = r * (1 + 0.22 * i)
+ cung(ctx, O, rr, tu: a1, den: a1 + d, mau: mau, day: day, n: 24)
+ }
+ // Vạch đánh dấu cắt ngang cung (ký hiệu góc bằng nhau)
+ if vach > 0 {
+ let tm = a1 + d / 2
+ let opt = toa-pt(ctx, O)
+ let ppt = toa-pt(ctx, (O.at(0) + r * calc.cos(tm), O.at(1) + r * calc.sin(tm)))
+ let rp = calc.sqrt(
+ calc.pow(ppt.at(0) - opt.at(0), 2) + calc.pow(ppt.at(1) - opt.at(1), 2)
+ )
+ if rp > 0.01 {
+ let da = (3.4 / rp) * 1rad // khoảng cách 2 vạch ~3.4pt trên cung
+ let hd = dai-vach.pt() / 2
+ for i in range(vach) {
+ let ang = tm + (i - (vach - 1) / 2) * da
+ let q = toa-pt(ctx, (O.at(0) + r * calc.cos(ang), O.at(1) + r * calc.sin(ang)))
+ let l = calc.sqrt(
+ calc.pow(q.at(0) - opt.at(0), 2) + calc.pow(q.at(1) - opt.at(1), 2)
+ )
+ let (ux, uy) = ((q.at(0) - opt.at(0)) / l, (q.at(1) - opt.at(1)) / l)
+ doan-pt(
+ (q.at(0) - hd * ux, q.at(1) - hd * uy),
+ (q.at(0) + hd * ux, q.at(1) + hd * uy),
+ mau: mau, day: day,
+ )
+ }
+ }
+ }
+ let nd = if ten != none { ten } else if so-do {
+ let dd = if d < 0deg { -d } else { d }
+ let v = calc.round(dd.deg(), digits: 1)
+ let s = if calc.abs(v - calc.round(v)) < 0.01 { str(int(calc.round(v))) } else { str(v) }
+ $#s degree$
+ } else { none }
+ if nd != none {
+ let tm = a1 + d / 2
+ nhan(
+ ctx,
+ (O.at(0) + r * cach-nhan * calc.cos(tm), O.at(1) + r * cach-nhan * calc.sin(tm)),
+ nd, huong: "giua", mau: mau,
+ )
+ }
+}
+
+// Ký hiệu góc vuông tại O (giữa hai tia O->A, O->B).
+/// Ký hiệu góc vuông TẠI O (giữa hai tia O→A, O→B). ĐỈNH là đối số ĐẦU.
+/// Quen lối TikZ (đỉnh ở giữa) thì dùng `ve-goc-vuong(A, O, B)`.
+#let goc-vuong(ctx, O, A, B, r: 0.32, mau: black, day: 0.8pt) = {
+ let ka = calc.sqrt(calc.pow(A.at(0) - O.at(0), 2) + calc.pow(A.at(1) - O.at(1), 2))
+ let kb = calc.sqrt(calc.pow(B.at(0) - O.at(0), 2) + calc.pow(B.at(1) - O.at(1), 2))
+ if ka == 0 or kb == 0 { return }
+ let u = ((A.at(0) - O.at(0)) / ka * r, (A.at(1) - O.at(1)) / ka * r)
+ let v = ((B.at(0) - O.at(0)) / kb * r, (B.at(1) - O.at(1)) / kb * r)
+ let P1 = (O.at(0) + u.at(0), O.at(1) + u.at(1))
+ let P2 = (O.at(0) + u.at(0) + v.at(0), O.at(1) + u.at(1) + v.at(1))
+ let P3 = (O.at(0) + v.at(0), O.at(1) + v.at(1))
+ duong-cong(ctx, (P1, P2, P3), mau: mau, day: day)
+}
+
+// ---------- Lối viết TikZ: ĐỈNH góc đặt ở GIỮA ----------
+// `goc`/`goc-vuong` ở trên đặt ĐỈNH góc làm đối số ĐẦU: goc-vuong(O, A, B).
+// TikZ lại viết đỉnh ở GIỮA (pic angle = A--O--B), nên hai hàm dưới đây nhận
+// thứ tự quen thuộc đó — đỉnh là đối số THỨ HAI:
+// ve-goc(A, O, B, ...) <=> goc(O, A, B, ...)
+// ve-goc-vuong(A, O, B, ...) <=> goc-vuong(O, A, B, ...)
+// Mọi tuỳ chọn (r, ten, so-do, so-cung, vach, to, mau, day, cach-nhan…) giữ
+// nguyên tên và được truyền thẳng xuống hàm gốc, nên hai lối dùng lẫn được
+// trong cùng một hình. Hàm gốc KHÔNG đổi ⇒ mọi bài cũ chạy y như trước.
+/// Đánh dấu góc TẠI O theo lối TikZ: ĐỈNH nằm GIỮA — `ve-goc(A, O, B)`.
+/// Cùng bộ tuỳ chọn với `goc` (r, ten, so-do, so-cung, vach, to, mau, day, cach-nhan).
+#let ve-goc(ctx, A, O, B, ..tuy-chon) = goc(ctx, O, A, B, ..tuy-chon)
+
+/// Ký hiệu góc vuông TẠI O theo lối TikZ: ĐỈNH nằm GIỮA — `ve-goc-vuong(A, O, B)`.
+/// Cùng bộ tuỳ chọn với `goc-vuong` (r, mau, day).
+#let ve-goc-vuong(ctx, A, O, B, ..tuy-chon) = goc-vuong(ctx, O, A, B, ..tuy-chon)
+
+// ---------- Đánh dấu đoạn bằng nhau (1..3 vạch tại trung điểm) ----------
+// so : số vạch song song (1/2/3…) tại trung điểm đoạn.
+// nghieng: góc NGHIÊNG của vạch so với pháp tuyến đoạn (0deg = vuông góc như cũ);
+// đặt ~20–30deg cho kiểu vạch chéo "/" "//" "///".
+// cheo : true = mỗi mốc vẽ dấu CHÉO NHAU "✕" (hai vạch ±góc); khi đó `nghieng`
+// = nửa góc mở của dấu ✕ (mặc định 30deg nếu để 0deg).
+#let danh-dau(ctx, A, B, so: 1, dai: 6pt, mau: black, day: 1pt,
+ nghieng: 0deg, cheo: false) = {
+ let a = toa-pt(ctx, A)
+ let b = toa-pt(ctx, B)
+ let dx = b.at(0) - a.at(0)
+ let dy = b.at(1) - a.at(1)
+ let l = calc.sqrt(dx * dx + dy * dy)
+ if l == 0 { return }
+ let ux = dx / l
+ let uy = dy / l
+ let m = ((a.at(0) + b.at(0)) / 2, (a.at(1) + b.at(1)) / 2)
+ let hd = dai.pt() / 2
+ // vẽ MỘT vạch tại tâm c, nghiêng góc g so với pháp tuyến (−uy, ux) của đoạn.
+ let mot-vach(c, g) = {
+ let cs = calc.cos(g)
+ let sn = calc.sin(g)
+ // pháp tuyến p = (−uy, ux); tiếp tuyến u = (ux, uy)
+ // vạch nghiêng: v = cos(g)·p + sin(g)·u
+ let vx = -uy * cs + ux * sn
+ let vy = ux * cs + uy * sn
+ doan-pt(
+ (c.at(0) - hd * vx, c.at(1) - hd * vy),
+ (c.at(0) + hd * vx, c.at(1) + hd * vy),
+ mau: mau, day: day,
+ )
+ }
+ for i in range(so) {
+ let t = (i - (so - 1) / 2) * 3.2
+ let c = (m.at(0) + t * ux, m.at(1) + t * uy)
+ if cheo {
+ let g = if nghieng == 0deg { 30deg } else { nghieng }
+ mot-vach(c, g)
+ mot-vach(c, -g)
+ } else {
+ mot-vach(c, nghieng)
+ }
+ }
+}
+
+// ---------- Tiện ích hình học phẳng (tính toán) ----------
+/// TRẢ VỀ trung điểm của A và B (chỉ tính, không vẽ).
+#let trung-diem(A, B) = ((A.at(0) + B.at(0)) / 2, (A.at(1) + B.at(1)) / 2)
+
+#let khoang-cach(A, B) = calc.sqrt(
+ calc.pow(B.at(0) - A.at(0), 2) + calc.pow(B.at(1) - A.at(1), 2)
+)
+
+// Điểm chia: A + t*(B - A).
+/// TRẢ VỀ điểm M trên đường AB với AM/AB = t (t = 0.5 là trung điểm,
+/// t > 1 là kéo dài quá B, t < 0 là ngược về phía A).
+#let chia(A, B, t) = (
+ A.at(0) + t * (B.at(0) - A.at(0)),
+ A.at(1) + t * (B.at(1) - A.at(1)),
+)
+
+// Chân đường vuông góc hạ từ P xuống đường thẳng AB.
+/// TRẢ VỀ hình chiếu vuông góc của P lên đường thẳng AB (chân đường cao).
+#let hinh-chieu(P, A, B) = {
+ let ux = B.at(0) - A.at(0)
+ let uy = B.at(1) - A.at(1)
+ let t = ((P.at(0) - A.at(0)) * ux + (P.at(1) - A.at(1)) * uy) / (ux * ux + uy * uy)
+ (A.at(0) + t * ux, A.at(1) + t * uy)
+}
+
+// ---------- Cửa sổ khung vừa khít hình (TRẢ GIÁ TRỊ, không vẽ) ----------
+// Gom mọi điểm mà một đối tượng chiếm chỗ (nội bộ của khung-vua).
+#let _diem-cua(m) = {
+ if type(m) != array or m.len() == 0 { return () }
+ let so = (int, float)
+ if m.len() == 2 and type(m.at(0)) in so and type(m.at(1)) in so {
+ (m,) // một ĐIỂM (x, y)
+ } else if m.len() == 2 and type(m.at(0)) == array and type(m.at(1)) in so {
+ let (O, r) = m // một ĐƯỜNG TRÒN (tâm, bán kính)
+ (
+ (O.at(0) - r, O.at(1)), (O.at(0) + r, O.at(1)),
+ (O.at(0), O.at(1) - r), (O.at(0), O.at(1) + r),
+ )
+ } else {
+ let ds = ()
+ for x in m { ds += _diem-cua(x) } // mảng điểm / mảng lồng nhau
+ ds
+ }
+}
+
+// Cửa sổ toạ độ vừa khít các đối tượng truyền vào — để hình KHÔNG tràn ra
+// ngoài khung. Mỗi đối số là một ĐIỂM (x, y), một ĐƯỜNG TRÒN (tâm, bán kính),
+// hoặc một MẢNG gồm các thứ đó.
+// #hinh(w: 5cm, ..khung-vua((A, B, C), (O, R)), ctx => { ... })
+// le: chừa lề quanh hình, tính theo TỈ LỆ cạnh lớn của hình (0.12 = 12%).
+/// TRẢ VỀ cửa sổ `(xmin:, xmax:, ymin:, ymax:)` vừa khít các điểm/đường tròn
+/// truyền vào, để rải thẳng vào `#hinh`:
+/// `#hinh(w: 5cm, ..khung-vua((A, B, C), (O, R)), ctx => { … })`.
+#let khung-vua(..muc, le: 0.12) = {
+ let ds = ()
+ for m in muc.pos() { ds += _diem-cua(m) }
+ if ds.len() == 0 { return (xmin: -5, xmax: 5, ymin: -4, ymax: 4) }
+ let xs = ds.map(P => P.at(0))
+ let ys = ds.map(P => P.at(1))
+ let (x1, x2) = (calc.min(..xs), calc.max(..xs))
+ let (y1, y2) = (calc.min(..ys), calc.max(..ys))
+ let d = calc.max(x2 - x1, y2 - y1, 0.001)
+ let m = d * le
+ (xmin: x1 - m, xmax: x2 + m, ymin: y1 - m, ymax: y2 + m)
+}
+
+// Tâm đường tròn ngoại tiếp tam giác ABC.
+#let tam-ngoai-tiep(A, B, C) = {
+ let d = 2 * (A.at(0) * (B.at(1) - C.at(1)) + B.at(0) * (C.at(1) - A.at(1)) + C.at(0) * (A.at(1) - B.at(1)))
+ let a2 = A.at(0) * A.at(0) + A.at(1) * A.at(1)
+ let b2 = B.at(0) * B.at(0) + B.at(1) * B.at(1)
+ let c2 = C.at(0) * C.at(0) + C.at(1) * C.at(1)
+ (
+ (a2 * (B.at(1) - C.at(1)) + b2 * (C.at(1) - A.at(1)) + c2 * (A.at(1) - B.at(1))) / d,
+ (a2 * (C.at(0) - B.at(0)) + b2 * (A.at(0) - C.at(0)) + c2 * (B.at(0) - A.at(0))) / d,
+ )
+}
+
+// Đường tròn ĐI QUA một dãy điểm — dùng cho đường tròn ngoại tiếp ĐA GIÁC.
+// 3 điểm: đúng bằng đường tròn ngoại tiếp tam giác. Nhiều hơn 3: khớp theo
+// bình phương bé nhất (đa giác NỘI TIẾP ĐƯỢC vẫn cho đúng đường tròn của nó,
+// đa giác vẽ hơi lệch thì cho đường tròn sát nhất — không lệch hẳn về 3 đỉnh
+// đầu như khi chỉ lấy 3 điểm).
+// TRẢ VỀ (tâm, bán kính).
+#let tron-qua-diem(ds) = {
+ let n = ds.len()
+ if n < 3 { panic("tron-qua-diem: cần ít nhất 3 điểm") }
+ let xm = ds.map(P => P.at(0)).sum() / n
+ let ym = ds.map(P => P.at(1)).sum() / n
+ let us = ds.map(P => P.at(0) - xm)
+ let vs = ds.map(P => P.at(1) - ym)
+ let s(f) = range(n).map(i => f(us.at(i), vs.at(i))).sum()
+ let suu = s((u, v) => u * u)
+ let svv = s((u, v) => v * v)
+ let suv = s((u, v) => u * v)
+ let a1 = (s((u, v) => u * u * u) + s((u, v) => u * v * v)) / 2
+ let a2 = (s((u, v) => v * v * v) + s((u, v) => v * u * u)) / 2
+ let d = suu * svv - suv * suv
+ if calc.abs(d) < 1e-12 { panic("tron-qua-diem: các điểm thẳng hàng") }
+ let uc = (a1 * svv - a2 * suv) / d
+ let vc = (a2 * suu - a1 * suv) / d
+ (
+ (xm + uc, ym + vc),
+ calc.sqrt(uc * uc + vc * vc + (suu + svv) / n),
+ )
+}
+
+// Tâm đường tròn nội tiếp + bán kính: trả về (I, r).
+#let tam-noi-tiep(A, B, C) = {
+ let a = khoang-cach(B, C)
+ let b = khoang-cach(C, A)
+ let c = khoang-cach(A, B)
+ let p = a + b + c
+ let I = (
+ (a * A.at(0) + b * B.at(0) + c * C.at(0)) / p,
+ (a * A.at(1) + b * B.at(1) + c * C.at(1)) / p,
+ )
+ let s = p / 2
+ let r = calc.sqrt((s - a) * (s - b) * (s - c) / s)
+ (I, r)
+}
+
+// Trực tâm tam giác ABC (giao ba đường cao).
+// Dùng hệ thức Euler: vec(OH) = vec(OA) + vec(OB) + vec(OC) => H = A + B + C - 2O.
+#let truc-tam(A, B, C) = {
+ let O = tam-ngoai-tiep(A, B, C)
+ (
+ A.at(0) + B.at(0) + C.at(0) - 2 * O.at(0),
+ A.at(1) + B.at(1) + C.at(1) - 2 * O.at(1),
+ )
+}
+
+// Tâm bàng tiếp + bán kính đường tròn bàng tiếp TRONG GÓC A
+// (tiếp xúc cạnh BC và phần kéo dài của AB, AC): trả về (J, r).
+// J_A = (-a*A + b*B + c*C) / (-a + b + c), r_A = S / (p - a)
+// Muốn tâm bàng tiếp trong góc B thì gọi tam-bang-tiep(B, C, A).
+#let tam-bang-tiep(A, B, C) = {
+ let a = khoang-cach(B, C)
+ let b = khoang-cach(C, A)
+ let c = khoang-cach(A, B)
+ let t = -a + b + c
+ let J = (
+ (-a * A.at(0) + b * B.at(0) + c * C.at(0)) / t,
+ (-a * A.at(1) + b * B.at(1) + c * C.at(1)) / t,
+ )
+ let s = (a + b + c) / 2
+ let S = calc.sqrt(s * (s - a) * (s - b) * (s - c))
+ (J, S / (s - a))
+}
+
+// Hàm bậc nhất y = f(x) của đường thẳng qua 2 điểm A, B (AB không thẳng đứng)
+// — dùng ghép với giao-ham để tìm giao đường thẳng với đường cong.
+#let ham-qua-2-diem(A, B) = {
+ let a = (B.at(1) - A.at(1)) / (B.at(0) - A.at(0))
+ x => A.at(1) + a * (x - A.at(0))
+}
+
+// Giao điểm của 2 đồ thị y = f(x) và y = g(x) trên [tu, den]:
+// quét n mẫu tìm chỗ f − g đổi dấu rồi chia đôi 40 lần (chính xác ~1e-12).
+// Trả về MẢNG các điểm (x, f(x)) theo x tăng dần (rỗng nếu không cắt).
+// Giao đường thẳng AB với đường cong f: giao-ham(f, ham-qua-2-diem(A, B), ...);
+// đường thẳng ĐỨNG x = k thì giao là (k, f(k)) — không cần hàm này.
+//
+// CÁCH GỌI (hàm này chỉ TÍNH, không vẽ — không cần ctx):
+// giao-ham(f, g, -3, 2) // cận positional
+// giao-ham(f, g, tu: -3, den: 2) // hoặc cận đặt tên (như ve-ham)
+// for P in giao-ham(f, g, -3, 2) { diem(ctx, P, mau: red) }
+// Lỡ truyền ctx ở đầu (giao-ham(ctx, f, g, ...)) thì tự bỏ qua, không lỗi.
+// Chỗ f, g "nhảy" qua tiệm cận đứng cũng đổi dấu — các điểm giả đó bị LOẠI
+// (kiểm tra lại |f − g| tại nghiệm), nên quét qua tiệm cận vẫn an toàn.
+#let giao-ham(..thamso, tu: auto, den: auto, n: 200) = {
+ let p = thamso.pos()
+ // bỏ qua ctx nếu lỡ truyền vào (ctx là dictionary, f/g là hàm)
+ if p.len() > 0 and type(p.at(0)) == dictionary { p = p.slice(1) }
+ let f = p.at(0)
+ let g = p.at(1)
+ let tu = if p.len() > 2 { p.at(2) } else { tu }
+ let den = if p.len() > 3 { p.at(3) } else { den }
+ let h = x => f(x) - g(x)
+ let kq = ()
+ // lệch mẫu một lượng cực nhỏ để không rơi ĐÚNG vào điểm hàm không xác định
+ // (vd x = −1 của (x+2)/(x+1) — Typst báo lỗi "cannot divide by zero")
+ let le = 0.0000001
+ for i in range(n) {
+ let x1 = tu + (den - tu) * (i + le) / n
+ let x2 = tu + (den - tu) * (i + 1 + le) / n
+ let (y1, y2) = (h(x1), h(x2))
+ if y1 * y2 <= 0 and (y1 != 0 or y2 != 0) {
+ let (lo, hi) = (x1, x2)
+ for _ in range(40) {
+ let m = (lo + hi) / 2
+ if h(lo) * h(m) <= 0 { hi = m } else { lo = m }
+ }
+ let x = (lo + hi) / 2
+ let y = f(x)
+ // nghiệm THẬT: f(x) ≈ g(x); tiệm cận đứng cho |f − g| rất lớn -> bỏ
+ let that = calc.abs(h(x)) < 0.000001 * calc.max(1, calc.abs(y))
+ if that and (kq.len() == 0 or x - kq.last().at(0) > (den - tu) / n * 0.5) {
+ kq.push((x, y))
+ }
+ }
+ }
+ kq
+}
+
+// Hai tiếp điểm của tiếp tuyến kẻ từ điểm M ngoài đường tròn (O; r).
+#let tiep-diem(O, r, M) = {
+ let d = khoang-cach(O, M)
+ let a = r * r / d // khoảng cách từ O đến hình chiếu tiếp điểm trên OM
+ let hcao = r * calc.sqrt(d * d - r * r) / d
+ let ux = (M.at(0) - O.at(0)) / d
+ let uy = (M.at(1) - O.at(1)) / d
+ let H = (O.at(0) + a * ux, O.at(1) + a * uy)
+ (
+ (H.at(0) - hcao * uy, H.at(1) + hcao * ux),
+ (H.at(0) + hcao * uy, H.at(1) - hcao * ux),
+ )
+}
+
+// ---------- Lấy mẫu hàm số ----------
+#let lay-mau(f, a, b, n: 100) = range(n + 1).map(i => {
+ let x = a + (b - a) * (i / n)
+ (x, f(x))
+})
+
+// Vẽ đồ thị hàm f trên [tu, den]; tự tách nhánh khi ra ngoài cửa sổ y
+// (dùng được cho hàm có tiệm cận đứng như 1/x, tan x).
+#let ve-ham(
+ ctx, f,
+ tu: auto, den: auto,
+ n: 150, mau: blue, day: 1.3pt, dut: false,
+) = {
+ let a = if tu == auto { ctx.xmin } else { tu }
+ let b = if den == auto { ctx.xmax } else { den }
+ let le-y = (ctx.ymax - ctx.ymin) * 0.02
+ let nhanh = ()
+ let hien-tai = ()
+ for p in lay-mau(f, a, b, n: n) {
+ if p.at(1) >= ctx.ymin - le-y and p.at(1) <= ctx.ymax + le-y {
+ hien-tai.push(p)
+ } else {
+ if hien-tai.len() > 1 { nhanh.push(hien-tai) }
+ hien-tai = ()
+ }
+ }
+ if hien-tai.len() > 1 { nhanh.push(hien-tai) }
+ for nh in nhanh {
+ duong-cong(ctx, nh, mau: mau, day: day, dut: dut)
+ }
+}
+
+// Tô miền giữa đồ thị f và trục hoành trên [a, b] (mau nên có độ trong suốt).
+#let to-vung(ctx, f, a, b, mau: rgb(30, 100, 200, 60), n: 80) = {
+ let pts = lay-mau(f, a, b, n: n)
+ da-giac-pt(((a, 0), ..pts, (b, 0)).map(P => toa-pt(ctx, P)), to: mau)
+}
+
+// Tô miền giữa HAI đồ thị f và g trên [a, b] (mau nên có độ trong suốt).
+#let to-vung-2-ham(ctx, f, g, a, b, mau: rgb(30, 100, 200, 60), n: 80) = {
+ let tren = lay-mau(f, a, b, n: n)
+ let duoi = lay-mau(g, a, b, n: n).rev()
+ da-giac-pt((tren + duoi).map(P => toa-pt(ctx, P)), to: mau)
+}
+
+// ---------- Gạch chéo (miền loại bỏ của BPT, vùng cấm...) ----------
+// Toạ độ trang (pt) -> toạ độ toán (nghịch đảo của toa-pt).
+#let toa-nguoc(ctx, p) = (
+ ctx.xmin + p.at(0) / ctx.sx.pt(),
+ ctx.ymax - p.at(1) / ctx.sy.pt(),
+)
+
+// Gạch chéo một đa giác LỒI cho trước bằng các đoạn song song.
+// pts: toạ độ pt. goc: hướng vạch gạch. buoc: khoảng cách 2 vạch.
+#let _gach-loi-pt(pts, goc: 45deg, buoc: 6.5pt, mau: red, day: 0.55pt) = {
+ if pts.len() < 3 { return }
+ let d = (calc.cos(goc), -calc.sin(goc)) // trục y trang hướng xuống
+ let nv = (-d.at(1), d.at(0))
+ let ts = pts.map(p => p.at(0) * nv.at(0) + p.at(1) * nv.at(1))
+ let t = calc.min(..ts) + buoc.pt() / 2
+ let t1 = calc.max(..ts)
+ while t < t1 {
+ // giao của đường {X · nv = t} với từng cạnh đa giác
+ let gd = ()
+ for i in range(pts.len()) {
+ let P = pts.at(i)
+ let Q = pts.at(calc.rem(i + 1, pts.len()))
+ let a1 = P.at(0) * nv.at(0) + P.at(1) * nv.at(1)
+ let a2 = Q.at(0) * nv.at(0) + Q.at(1) * nv.at(1)
+ if calc.abs(a2 - a1) > 0.000001 {
+ let s = (t - a1) / (a2 - a1)
+ if s >= 0 and s < 1 {
+ gd.push((P.at(0) + s * (Q.at(0) - P.at(0)), P.at(1) + s * (Q.at(1) - P.at(1))))
+ }
+ }
+ }
+ let gd = gd.sorted(key: p => p.at(0) * d.at(0) + p.at(1) * d.at(1))
+ let i = 0
+ while i + 1 < gd.len() {
+ doan-pt(gd.at(i), gd.at(i + 1), mau: mau, day: day)
+ i = i + 2
+ }
+ t = t + buoc.pt()
+ }
+}
+
+// Gạch chéo đa giác lồi theo toạ độ TOÁN.
+#let gach-mien(ctx, cac-dinh, goc: 45deg, buoc: 6.5pt, mau: red, day: 0.55pt) = {
+ _gach-loi-pt(cac-dinh.map(P => toa-pt(ctx, P)), goc: goc, buoc: buoc, mau: mau, day: day)
+}
+
+// Cắt đa giác bởi nửa mặt phẳng a*x + b*y <= c (Sutherland–Hodgman, toạ độ toán).
+#let cat-nua-mp(pts, a, b, c) = {
+ let kq = ()
+ let n = pts.len()
+ for i in range(n) {
+ let P = pts.at(i)
+ let Q = pts.at(calc.rem(i + 1, n))
+ let fp = a * P.at(0) + b * P.at(1) - c
+ let fq = a * Q.at(0) + b * Q.at(1) - c
+ if fp <= 0.000001 { kq.push(P) }
+ if fp * fq < 0 {
+ let s = fp / (fp - fq)
+ kq.push((P.at(0) + s * (Q.at(0) - P.at(0)), P.at(1) + s * (Q.at(1) - P.at(1))))
+ }
+ }
+ kq
+}
+
+// Điểm P có thuộc hình tròn tâm O bán kính r không (toạ độ toán)?
+#let trong-tron(P, O, r) = (
+ calc.pow(P.at(0) - O.at(0), 2) + calc.pow(P.at(1) - O.at(1), 2) <= r * r
+)
+
+// Điểm P có thuộc hình elip tâm O, bán trục a (ngang), b (dọc) không?
+// quay: cùng góc xoay với hàm vẽ elip(ctx, O, a, b, quay: ...).
+#let trong-elip(P, O, a, b, quay: 0deg) = {
+ let Q = if quay == 0deg { P } else { quay-diem(P, O, -quay) }
+ calc.pow((Q.at(0) - O.at(0)) / a, 2) + calc.pow((Q.at(1) - O.at(1)) / b, 2) <= 1
+}
+
+// ---------- Miền hình học ĐẶT TÊN (khai báo MỘT lần, vẽ + gạch dùng chung) ----------
+// let A = mien-tron((-0.5, 0.3), 0.9) // đổi bán kính MỘT chỗ duy nhất
+// ve-mien(ctx, A) // vẽ biên
+// gach-vung(ctx, giao(A, B, bu(C))) // gạch (A ∩ B) \ C — tự khớp
+#let mien-tron(O, r) = (kieu: "tron", O: O, r: r)
+#let mien-elip(O, a, b, quay: 0deg) = (kieu: "elip", O: O, a: a, b: b, quay: quay)
+
+// Điểm P có thuộc miền m không? m là mien-tron/mien-elip HOẶC hàm P => bool.
+#let trong(P, m) = {
+ if type(m) == function { m(P) } else if m.kieu == "tron" {
+ trong-tron(P, m.O, m.r)
+ } else {
+ trong-elip(P, m.O, m.a, m.b, quay: m.quay)
+ }
+}
+
+// Phép tập hợp trên miền: giao ∩, hợp ∪, bù (phần ngoài) — lồng nhau tuỳ ý:
+// giao(A, hop(B, C)), giao(A, B, bu(C)), ... Kết quả dùng thẳng cho gach-vung.
+#let giao(..ms) = P => ms.pos().all(m => trong(P, m))
+#let hop(..ms) = P => ms.pos().any(m => trong(P, m))
+#let bu(m) = P => not trong(P, m)
+
+// Vẽ biên miền (đường tròn/elip tương ứng với khai báo).
+#let ve-mien(ctx, m, mau: black, day: 1pt, dut: false, to: none) = {
+ if m.kieu == "tron" {
+ duong-tron(ctx, m.O, m.r, mau: mau, day: day, dut: dut, to: to)
+ } else {
+ elip(ctx, m.O, m.a, m.b, quay: m.quay, mau: mau, day: day, dut: dut, to: to)
+ }
+}
+
+// Gạch chéo MIỀN BẤT KÌ (biên cong tuỳ ý).
+// kiem: miền đặt tên/tổ hợp giao-hop-bu (khuyên dùng) HOẶC hàm P => true/false:
+// gach-vung(ctx, giao(A, B, bu(C)))
+// gach-vung(ctx, P => trong(P, A) and P.at(1) > 0)
+// goc: hướng vạch; buoc: khoảng cách 2 vạch; n: số mẫu mỗi vạch (biên càng mịn).
+#let gach-vung(ctx, kiem, goc: 45deg, buoc: 6.5pt, mau: red, day: 0.55pt, n: 180) = {
+ let W = ctx.w.pt()
+ let H = ctx.h.pt()
+ let u = (calc.cos(goc), -calc.sin(goc)) // hướng vạch trên trang (y trang hướng xuống)
+ let v = (-u.at(1), u.at(0)) // pháp tuyến đơn vị
+ let b = buoc.pt()
+ let cac-goc = ((0, 0), (W, 0), (0, H), (W, H))
+ let ds = cac-goc.map(p => p.at(0) * v.at(0) + p.at(1) * v.at(1))
+ let ts = cac-goc.map(p => p.at(0) * u.at(0) + p.at(1) * u.at(1))
+ let dmin = calc.min(..ds)
+ let dmax = calc.max(..ds)
+ let tmin = calc.min(..ts)
+ let tmax = calc.max(..ts)
+ for i in range(calc.floor((dmax - dmin) / b) + 1) {
+ let d = dmin + i * b
+ let dau = none
+ let truoc = none
+ for j in range(n + 1) {
+ let t = tmin + (tmax - tmin) * j / n
+ let p = (d * v.at(0) + t * u.at(0), d * v.at(1) + t * u.at(1))
+ let thuoc = (
+ p.at(0) >= 0 and p.at(0) <= W and p.at(1) >= 0 and p.at(1) <= H
+ and trong(toa-nguoc(ctx, p), kiem)
+ )
+ if thuoc and dau == none { dau = p }
+ if not thuoc and dau != none {
+ doan-pt(dau, truoc, mau: mau, day: day)
+ dau = none
+ }
+ truoc = p
+ }
+ if dau != none { doan-pt(dau, truoc, mau: mau, day: day) }
+ }
+}
diff --git a/packages/preview/conic-toan/0.3.4/typst.toml b/packages/preview/conic-toan/0.3.4/typst.toml
new file mode 100644
index 0000000000..de301e12cb
--- /dev/null
+++ b/packages/preview/conic-toan/0.3.4/typst.toml
@@ -0,0 +1,13 @@
+[package]
+name = "conic-toan"
+version = "0.3.4"
+entrypoint = "baigiang.typ"
+authors = ["Kim Thu "]
+license = "MIT"
+description = "Draw math figures, graphs and variation tables."
+repository = "https://github.com/Thutran1891/conic-toan"
+keywords = ["math", "geometry", "plot", "slides", "exam", "vietnamese", "toan", "de-thi"]
+categories = ["visualization", "presentation", "components"]
+disciplines = ["mathematics", "education"]
+compiler = "0.13.0"
+exclude = ["/assets/"]