mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-11 16:32:22 -06:00
The options at MTEXT's "Specify opposite corner" prompt printed "not available yet". Each now asks its question and returns to the corner prompt: Height (also sets TEXTSIZE), Justify (TL..BR, the insertion point is the box's attachment point), Line spacing (At least/Exactly, then a factor such as 1.5x or a distance), Rotation (picked, or typed through the drawing's angle settings; the box turns with it), Style (? lists them), Width (replaces the opposite corner) and Columns (No; Dynamic and Static say columns are not available yet). MText stores the line spacing style (DXF group 73) so Exactly round-trips. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ee34f423cc
commit
3ffe1ca2af
@@ -485,6 +485,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
style: st.text_style.clone(),
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size * k,
|
||||
@@ -525,6 +526,7 @@ fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
style: "Standard".into(),
|
||||
contents: t.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}))?
|
||||
.hex(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, Vec3, arc_to_bulge, bulge_to_arc};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::*;
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("mtext", "Multiline Text", run_mtext)
|
||||
.menu(&["Draw", "Text", "Multiline Text..."])
|
||||
.alias(&["t", "mt"])
|
||||
.params("{at, text, height?, width?, attach?: 1..9, rotation?}")
|
||||
.params("{at, text, height?, width?, attach?: 1..9 | justify?: TL..BR, rotation?, style?, lineSpacing?, lineSpacingExact?}")
|
||||
.interactive(|_| Ok(Box::new(MTextM::default()))),
|
||||
]
|
||||
}
|
||||
@@ -352,16 +352,24 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if height <= 0.0 {
|
||||
return Err(bad("mtext", "height must be positive"));
|
||||
}
|
||||
let style = s.doc()?.header.str("TEXTSTYLE", "Standard");
|
||||
let style = match str_param(p, "style") {
|
||||
Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?,
|
||||
None => s.doc()?.header.str("TEXTSTYLE", "Standard"),
|
||||
};
|
||||
let attach = match str_param(p, "justify") {
|
||||
Some(j) => super::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?,
|
||||
None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
|
||||
};
|
||||
let k = EntityKind::MText(MText {
|
||||
insert: v3(at),
|
||||
height,
|
||||
width: f64_or(p, "width", 0.0).max(0.0),
|
||||
attach: p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
|
||||
attach,
|
||||
rotation: f64_or(p, "rotation", 0.0).to_radians(),
|
||||
style,
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: f64_or(p, "lineSpacing", 1.0),
|
||||
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
|
||||
});
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
@@ -1414,16 +1422,35 @@ struct MTextM {
|
||||
/// Paragraphs typed so far: one per input line, an empty line ends the text (as on
|
||||
/// AutoCAD's command line and in scripts).
|
||||
lines: Vec<String>,
|
||||
/// Height, justification, line spacing, rotation, style and width set by the options.
|
||||
opts: super::mtext_opts::MTextOpts,
|
||||
/// An option asking a further question.
|
||||
ask: Option<super::mtext_opts::MTextAsk>,
|
||||
}
|
||||
|
||||
impl MTextM {
|
||||
/// The box is known: the opposite corner was picked or the Width option answered.
|
||||
fn boxed(&self) -> bool {
|
||||
self.second.is_some() || self.opts.width.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MTextM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MTEXT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.opts = super::mtext_opts::MTextOpts::new(s);
|
||||
s.echo(format!("Current text style: \"{}\" Text height: {:.4} Annotative: No", self.opts.style, self.opts.height));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.first, self.second) {
|
||||
(None, _) => Prompt::new("Specify first corner", Accept::POINT),
|
||||
(Some(a), None) => Prompt::new("Specify opposite corner", Accept::POINT)
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
return q.prompt(&self.opts, a);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT)
|
||||
.kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"])
|
||||
.base(a),
|
||||
_ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT),
|
||||
@@ -1431,28 +1458,27 @@ impl Interactive for MTextM {
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.first, self.second, i) {
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
use super::mtext_opts::Outcome;
|
||||
self.ask = match q.input(s, i, &mut self.opts, a)? {
|
||||
Outcome::Ask(next) => Some(next),
|
||||
Outcome::Back | Outcome::Text => None,
|
||||
};
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.first, self.boxed(), i) {
|
||||
(None, _, Input::Point(p)) => self.first = Some(p),
|
||||
(Some(_), None, Input::Point(p)) => self.second = Some(p),
|
||||
(Some(_), Some(_), Input::Text(t)) if !t.is_empty() => self.lines.push(t),
|
||||
(Some(a), Some(b), Input::Text(_) | Input::Enter) => {
|
||||
(Some(_), false, Input::Point(p)) => self.second = Some(p),
|
||||
(Some(_), false, Input::Keyword(k)) => {
|
||||
self.ask =
|
||||
Some(super::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?);
|
||||
}
|
||||
(Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t),
|
||||
(Some(a), true, Input::Text(_) | Input::Enter) => {
|
||||
if self.lines.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let h = d.header.f64("TEXTSIZE", 0.2);
|
||||
let style = d.header.str("TEXTSTYLE", "Standard");
|
||||
let tl = Vec2::new(a.x.min(b.x), a.y.max(b.y));
|
||||
s.add_entity(EntityKind::MText(MText {
|
||||
insert: Vec3::new(tl.x, tl.y, 0.0),
|
||||
height: h,
|
||||
width: (a.x - b.x).abs(),
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style,
|
||||
contents: self.lines.join("\\P"),
|
||||
line_spacing: 1.0,
|
||||
}))?;
|
||||
s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(_, _, Input::Enter) => return Ok(Step::Done),
|
||||
@@ -1461,8 +1487,14 @@ impl Interactive for MTextM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.first, self.second) {
|
||||
(Some(a), None) => vec![lwpoly(rect_vertices(a, c), true)],
|
||||
match (self.first, self.boxed(), self.ask) {
|
||||
(Some(a), false, None) => {
|
||||
// The box, turned by the rotation about the first corner.
|
||||
let r = self.opts.rotation;
|
||||
let l = (c - a).rotate(-r);
|
||||
let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)];
|
||||
vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ mod layer;
|
||||
mod layout;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
mod pline_opts;
|
||||
mod props;
|
||||
mod qselect;
|
||||
|
||||
@@ -1250,6 +1250,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
style: style.text_style.clone(),
|
||||
contents: g.value.clone(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! MTEXT's options before the text (Height, Justify, Line spacing, Rotation, Style, Width,
|
||||
//! Columns) for the prompt machine in `draw.rs`.
|
||||
|
||||
use cadcraft_doc::MText;
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::curves;
|
||||
use super::machines::number;
|
||||
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// The justification keywords, in attachment order 1..=9.
|
||||
pub(super) const JUSTIFY: [&str; 9] = ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
|
||||
|
||||
/// Attachment code (1..=9) for a justification keyword.
|
||||
pub(super) fn attach_code(k: &str) -> Option<u8> {
|
||||
JUSTIFY.iter().position(|j| j.eq_ignore_ascii_case(k.trim())).map(|i| i as u8 + 1)
|
||||
}
|
||||
|
||||
/// What the options set before the text is typed.
|
||||
pub(super) struct MTextOpts {
|
||||
pub height: f64,
|
||||
pub attach: u8,
|
||||
/// Line spacing factor (1 = 5/3 of the text height between baselines).
|
||||
pub spacing: f64,
|
||||
pub exact: bool,
|
||||
pub rotation: f64,
|
||||
pub style: String,
|
||||
/// The Width option replaces the opposite corner.
|
||||
pub width: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for MTextOpts {
|
||||
fn default() -> Self {
|
||||
MTextOpts { height: 0.2, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style: "Standard".into(), width: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl MTextOpts {
|
||||
pub(super) fn new(s: &Session) -> Self {
|
||||
let (height, style) =
|
||||
s.doc().map(|d| (d.header.f64("TEXTSIZE", 0.2), d.header.str("TEXTSTYLE", "Standard"))).unwrap_or((0.2, "Standard".into()));
|
||||
MTextOpts { height, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style, width: None }
|
||||
}
|
||||
|
||||
/// The text box from the first corner and either the opposite corner or the Width answer:
|
||||
/// the insertion point is the box's attachment point, the box turned by the rotation about
|
||||
/// the first corner.
|
||||
pub(super) fn mtext(&self, first: Vec2, second: Option<Vec2>, contents: String) -> MText {
|
||||
let (insert, width) = match (second, self.width) {
|
||||
(Some(b), _) => {
|
||||
// The opposite corner in the box's own (rotated) frame.
|
||||
let l = (b - first).rotate(-self.rotation);
|
||||
let (x0, x1) = (l.x.min(0.0), l.x.max(0.0));
|
||||
let (y0, y1) = (l.y.min(0.0), l.y.max(0.0));
|
||||
let i = usize::from(self.attach.clamp(1, 9) - 1);
|
||||
let x = [x0, (x0 + x1) / 2.0, x1][i % 3];
|
||||
let y = [y1, (y0 + y1) / 2.0, y0][i / 3];
|
||||
(first + Vec2::new(x, y).rotate(self.rotation), x1 - x0)
|
||||
}
|
||||
(None, w) => (first, w.unwrap_or(0.0)),
|
||||
};
|
||||
MText {
|
||||
insert: Vec3::new(insert.x, insert.y, 0.0),
|
||||
height: self.height,
|
||||
width,
|
||||
attach: self.attach.clamp(1, 9),
|
||||
rotation: self.rotation,
|
||||
style: self.style.clone(),
|
||||
contents,
|
||||
line_spacing: self.spacing,
|
||||
line_spacing_exact: self.exact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A question an option asks before MTEXT goes back to "Specify opposite corner".
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MTextAsk {
|
||||
Height,
|
||||
Justify,
|
||||
SpacingType,
|
||||
SpacingFactor,
|
||||
Rotation,
|
||||
Style,
|
||||
Width,
|
||||
Columns,
|
||||
}
|
||||
|
||||
pub(super) enum Outcome {
|
||||
/// Ask this next (the same question again after an invalid answer).
|
||||
Ask(MTextAsk),
|
||||
/// Back to the opposite-corner prompt.
|
||||
Back,
|
||||
/// The Width answer replaces the opposite corner: go on to the text.
|
||||
Text,
|
||||
}
|
||||
|
||||
impl MTextAsk {
|
||||
pub(super) fn from_keyword(k: &str) -> Option<MTextAsk> {
|
||||
Some(match k {
|
||||
"Height" => MTextAsk::Height,
|
||||
"Justify" => MTextAsk::Justify,
|
||||
"Line spacing" => MTextAsk::SpacingType,
|
||||
"Rotation" => MTextAsk::Rotation,
|
||||
"Style" => MTextAsk::Style,
|
||||
"Width" => MTextAsk::Width,
|
||||
"Columns" => MTextAsk::Columns,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prompt(self, o: &MTextOpts, first: Vec2) -> Prompt {
|
||||
match self {
|
||||
MTextAsk::Height => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{:.4}", o.height)).base(first),
|
||||
MTextAsk::Justify => Prompt::new("Enter justification", curves::KW)
|
||||
.kw(&JUSTIFY)
|
||||
.default(JUSTIFY.get(usize::from(o.attach.clamp(1, 9) - 1)).copied().unwrap_or("TL")),
|
||||
MTextAsk::SpacingType => {
|
||||
Prompt::new("Enter line spacing type", curves::KW).kw(&["At least", "Exactly"]).default(if o.exact { "Exactly" } else { "At least" })
|
||||
}
|
||||
MTextAsk::SpacingFactor => Prompt::new("Enter line spacing factor or distance", Accept::NUMBER).default(format!("{}x", trim(o.spacing))),
|
||||
MTextAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default(trim(o.rotation.to_degrees())).base(first),
|
||||
MTextAsk::Style => Prompt::new("Enter style name", curves::KW).kw(&["?"]).default(o.style.clone()),
|
||||
MTextAsk::Width => Prompt::new("Specify width", Accept::POINT_OR_NUMBER).base(first),
|
||||
MTextAsk::Columns => Prompt::new("Enter column type", curves::KW).kw(&["Dynamic", "Static", "No"]).default("No"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn input(self, s: &mut Session, i: Input, o: &mut MTextOpts, first: Vec2) -> Result<Outcome> {
|
||||
let word = |i: &Input| match i {
|
||||
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_string()),
|
||||
_ => None,
|
||||
};
|
||||
match (self, &i) {
|
||||
(_, Input::Enter) => Ok(if self == MTextAsk::SpacingType { Outcome::Ask(MTextAsk::SpacingFactor) } else { Outcome::Back }),
|
||||
(MTextAsk::Height, Input::Point(p)) => set_height(s, o, first.dist(*p)),
|
||||
(MTextAsk::Height, Input::Text(t)) => set_height(s, o, number(t).unwrap_or(f64::NAN)),
|
||||
(MTextAsk::Justify, _) => {
|
||||
let k = word(&i).unwrap_or_default();
|
||||
o.attach = attach_code(&k).ok_or_else(|| other("Invalid option keyword."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::SpacingType, _) => {
|
||||
let k = word(&i).unwrap_or_default().to_ascii_lowercase();
|
||||
o.exact = match k.as_str() {
|
||||
"at least" | "a" | "at" => false,
|
||||
"exactly" | "e" => true,
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
};
|
||||
Ok(Outcome::Ask(MTextAsk::SpacingFactor))
|
||||
}
|
||||
(MTextAsk::SpacingFactor, Input::Text(t)) => {
|
||||
// "1.5x" is a factor; a plain number is the distance between lines (1x = 5/3 of the height).
|
||||
let t = t.trim();
|
||||
let f = match t.strip_suffix(['x', 'X']) {
|
||||
Some(f) => number(f),
|
||||
None => number(t).map(|d| d / (o.height * 5.0 / 3.0)),
|
||||
};
|
||||
o.spacing = f
|
||||
.filter(|f| (0.25 - 1e-9..=4.0 + 1e-9).contains(f))
|
||||
.ok_or_else(|| other("Requires a factor between 0.25x and 4x, or a distance."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Rotation, Input::Point(p)) => {
|
||||
o.rotation = first.angle_to(*p);
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Rotation, Input::Text(t)) => {
|
||||
o.rotation = s.angle_settings().direction(t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Style, _) => {
|
||||
let name = word(&i).unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
if name == "?" {
|
||||
let names: Vec<String> = d.text_styles.iter().map(|t| t.name.clone()).collect();
|
||||
s.echo(format!("Text styles: {}", names.join(", ")));
|
||||
return Ok(Outcome::Ask(MTextAsk::Style));
|
||||
}
|
||||
o.style = d.text_style(&name).map(|t| t.name.clone()).ok_or_else(|| other(format!("Cannot find text style \"{name}\".")))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Width, Input::Point(p)) => {
|
||||
// The distance along the text direction from the first corner.
|
||||
o.width = Some((*p - first).rotate(-o.rotation).x.abs());
|
||||
Ok(Outcome::Text)
|
||||
}
|
||||
(MTextAsk::Width, Input::Text(t)) => {
|
||||
o.width = Some(number(t).filter(|w| w.is_finite() && *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?);
|
||||
Ok(Outcome::Text)
|
||||
}
|
||||
(MTextAsk::Columns, _) => match word(&i).unwrap_or_default().to_ascii_lowercase().as_str() {
|
||||
"no" | "n" => Ok(Outcome::Back),
|
||||
"dynamic" | "d" | "static" | "s" => {
|
||||
s.echo("Text columns are not available yet; the text stays in one column.");
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
_ => Err(other("Invalid option keyword.")),
|
||||
},
|
||||
_ => Ok(Outcome::Ask(self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_height(s: &mut Session, o: &mut MTextOpts, h: f64) -> Result<Outcome> {
|
||||
if !h.is_finite() || h <= 0.0 {
|
||||
return Err(other("Requires a positive height."));
|
||||
}
|
||||
o.height = h;
|
||||
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
|
||||
/// A number without trailing zeros (`1.5`, `2`).
|
||||
fn trim(v: f64) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub fn bracket() -> Drawing {
|
||||
add(on("Notes"), text(Vec2::new(4.1, -0.3), 0.16, "FRONT VIEW"));
|
||||
add(
|
||||
on("Notes"),
|
||||
EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0 }),
|
||||
EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0, line_spacing_exact: false }),
|
||||
);
|
||||
let tb0 = Vec2::new(14.0, -0.5);
|
||||
add(on("Title"), lwpoly(rect_vertices(tb0, tb0 + Vec2::new(4.0, 2.0)), true));
|
||||
|
||||
Reference in New Issue
Block a user