feat(web): unify fonts and refine viewcube

Preload one language-aware font resource across the interface, drawing text, and navigation labels.\n\nRender the navigation cube, ring, outlines, and labels through a 4x MSAA target.
This commit is contained in:
Hakan Seven committed 2026-08-03 15:28:51 +03:00
1 parent 8dc642d40c
commit 53cf31f33d
26 files changed
+1337 -522

No files matched your search

+6 -6
View File
@@ -3059,12 +3059,12 @@ catalog-view-on-github-0c77991f08 = View on GitHub
catalog-view-home-03db4237cc = View: Home
catalog-view-e255e7fa17 = View: __ocs_fmt_0__
catalog-viewborder-4f1557c346 = ViewBorder
catalog-viewcube-back-a9ae372c70 = ViewCube Back
catalog-viewcube-bottom-60079487fc = ViewCube Bottom
catalog-viewcube-front-5a443ae527 = ViewCube Front
catalog-viewcube-left-97e396e807 = ViewCube Left
catalog-viewcube-right-cd11a69b7d = ViewCube Right
catalog-viewcube-top-03f5c8d677 = ViewCube Top
catalog-viewcube-back-a9ae372c70 = BACK
catalog-viewcube-bottom-60079487fc = BOTTOM
catalog-viewcube-front-5a443ae527 = FRONT
catalog-viewcube-left-97e396e807 = LEFT
catalog-viewcube-right-cd11a69b7d = RIGHT
catalog-viewcube-top-03f5c8d677 = TOP
catalog-viewport-09e594a52b = Viewport
catalog-viewport-configuration-79f0f57a22 =
Viewport
+15 -1
View File
@@ -1550,6 +1550,13 @@ pub enum Message {
crate::scene::text::web_font::Script,
Result<Vec<u8>, String>,
),
/// Register a font already held by the shared web store with the UI renderer.
ApplyWebFont(crate::scene::text::web_font::Script),
/// Completion of the UI renderer's runtime font registration.
WebUiFontLoaded(
crate::scene::text::web_font::Script,
Result<(), String>,
),
/// Ctrl+V. Routed by `update`: into the open text/MText editor (via an async
/// system-clipboard read, which is the only paste path that works on the
/// web) or, with no editor open, the entity paste command.
@@ -3309,6 +3316,13 @@ impl OpenCADStudio {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut s = Self::new();
let focus = s.focus_cmd_input();
let primary_font = crate::scene::text::web_font::preload_language(
&crate::i18n::active_language_tag(),
);
let fonts = Task::batch([
Task::done(Message::PollWebFonts),
Task::done(Message::ApplyWebFont(primary_font)),
]);
// Web can't reach the Patreon API directly (CORS); fetch the CI-built
// supporters.json served on the same origin instead.
let patrons = Task::perform(
@@ -3328,7 +3342,7 @@ impl OpenCADStudio {
let thumbs_fetch = s.refresh_recent_thumbs();
(
s,
Task::batch([focus, patrons, videos, discussions, thumbs_fetch]),
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch]),
)
}
}
+41 -6
View File
@@ -255,9 +255,8 @@ impl OpenCADStudio {
fn update_inner(&mut self, msg: Message) -> Task<Message> {
match msg {
// Web: a drawing referenced a script whose Noto subset isn't loaded
// yet (recorded during text tessellation). Kick off one fetch per
// pending script; the result comes back as `WebFontLoaded`. (#141)
// Web: fetch every script queued by startup language selection or
// drawing text discovery. Each script has one shared store entry.
Message::PollWebFonts => {
let pending = crate::scene::text::web_font::take_pending();
if pending.is_empty() {
@@ -270,9 +269,8 @@ impl OpenCADStudio {
}))
}
// Web: a per-script font arrived. Store it, drop the stale fallback
// glyph cache (entries that resolved to nothing while it loaded),
// and re-tessellate so the text appears. (#141)
// Web: a per-script font arrived. The same bytes feed drawing text,
// the UI renderer, and the navigation-cube label atlas.
Message::WebFontLoaded(script, res) => {
match res {
Ok(bytes) => {
@@ -281,6 +279,7 @@ impl OpenCADStudio {
for tab in self.tabs.iter_mut() {
tab.scene.invalidate_text_geometry_dependencies();
}
return Task::done(Message::ApplyWebFont(script));
}
Err(e) => {
crate::scene::text::web_font::insert(script, None);
@@ -291,6 +290,32 @@ impl OpenCADStudio {
Task::none()
}
Message::ApplyWebFont(script) => {
let Some(bytes) = crate::scene::text::web_font::loaded(script) else {
return Task::none();
};
iced::font::load((*bytes).clone()).map(move |result| {
Message::WebUiFontLoaded(
script,
result.map_err(|error| format!("{error:?}")),
)
})
}
Message::WebUiFontLoaded(script, result) => {
if let Err(error) = result {
self.command_line.push_error(
crate::tf!("Font load failed ({script:?}): {error}").as_ref(),
);
return Task::none();
}
if script == crate::scene::text::web_font::primary_script() {
let family = iced::font::Family::name(script.family());
return iced::font::set_defaults(iced::Font::with_family(family), 16.0);
}
Task::none()
}
Message::Tick(t) => self.on_tick(t),
Message::OpenFile => self.on_open_file(),
@@ -4436,6 +4461,16 @@ impl OpenCADStudio {
Ok(()) => {
self.language = language;
self.persist_settings_if_changed();
#[cfg(target_arch = "wasm32")]
{
let script = crate::scene::text::web_font::preload_language(
&crate::i18n::active_language_tag(),
);
return Task::batch([
Task::done(Message::PollWebFonts),
Task::done(Message::ApplyWebFont(script)),
]);
}
}
Err(error) => self
.command_line
+11
View File
@@ -122,6 +122,7 @@ fn load_language(
if requested.is_empty() {
requested.push(loader.fallback_language().clone());
}
#[allow(unused_variables)]
let selected = i18n_embed::select(loader, &Localizations, &requested)?;
#[cfg(target_arch = "wasm32")]
if let Some(language) = selected.first() {
@@ -155,6 +156,16 @@ pub fn set_language(language: Language) -> Result<(), i18n_embed::I18nEmbedError
load_language(loader(), language)
}
#[cfg(target_arch = "wasm32")]
pub fn active_language_tag() -> String {
loader()
.current_languages()
.into_iter()
.next()
.unwrap_or_else(|| loader().fallback_language().clone())
.to_string()
}
/// Translate an application-facing source label from the complete UI catalog.
///
/// Stable semantic ids remain preferable for new code. This compatibility
+14 -9
View File
@@ -487,17 +487,19 @@ pub fn tessellate(
};
let anno = anno_scale as f64;
// SDF text is decided PER GROUP by whether the group carries a
// `GlyphRun`: a run-group renders as textured quads (built
// below) so its strokes are suppressed; a run-less group keeps
// its strokes. TEXT / MTEXT / dim / block / mleader text are all
// run-groups → fully SDF; a composite object that packs geometry
// and text into one Text object (a tolerance frame: box lines =
// run-less, cell text = run-groups) keeps the geometry as
// strokes and draws only the text as SDF.
// Run groups normally render as textured quads. Web runs that
// require bidi or joined-script shaping keep their already
// shaped vector geometry because the per-glyph SDF path has no
// cluster-position data.
for group in stroke_groups
.iter()
.filter(|g| force_text_strokes || g.run.is_none())
.filter(|group| {
force_text_strokes
|| group.run.is_none()
|| group.run.as_ref().is_some_and(|run| {
crate::scene::text::web_font::requires_shaping(&run.text)
})
})
{
let lx_v = group.origin[0];
let ly_v = group.origin[1];
@@ -548,6 +550,9 @@ pub fn tessellate(
// colours (bin key) win, falling back to entity colour.
for group in &stroke_groups {
let Some(run) = &group.run else { continue };
if crate::scene::text::web_font::requires_shaping(&run.text) {
continue;
}
let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v;
let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v;
// Base colour only (inline `\C` wins). Selection /
+1 -1
View File
@@ -130,7 +130,7 @@ pub use pick::selection_state::SelectionState;
pub use pipeline::uniforms::Uniforms;
pub use pipeline::viewcube::{
hit_test, hit_test_cardinal, hover_id, CubeRegion, NudgeDir, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD,
VIEWCUBE_PX, VIEWCUBE_REGION_PX,
VIEWCUBE_PX, VIEWCUBE_REGION_PX, VIEWCUBE_RENDER_PX,
};
use view::camera::Camera;
pub use view::camera::Projection;
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Weight, Wrap};
pub(super) const ATLAS_WIDTH: u32 = 512;
pub(super) const ATLAS_HEIGHT: u32 = 256;
pub(super) const FACE_TILE_COUNT: usize = 6;
pub(super) const CARDINAL_TILE_START: usize = FACE_TILE_COUNT;
pub(super) const TILE_COUNT: usize = FACE_TILE_COUNT + 4;
const FONT_PX: f32 = 32.0;
const LINE_PX: f32 = 40.0;
const TILE_PADDING: i32 = 3;
const TILE_GAP: u32 = 3;
const EMBOLDEN_RADIUS: i32 = 1;
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct AtlasTile {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
pub aspect: f32,
}
pub(super) struct LabelAtlas {
pub pixels: Vec<u8>,
pub tiles: [AtlasTile; TILE_COUNT],
}
struct LabelBitmap {
width: u32,
height: u32,
pixels: Vec<u8>,
}
pub(super) fn empty_label_atlas() -> LabelAtlas {
LabelAtlas {
pixels: vec![0_u8; (ATLAS_WIDTH * ATLAS_HEIGHT) as usize],
tiles: [AtlasTile::default(); TILE_COUNT],
}
}
pub(super) fn build_label_atlas(
labels: &[String; FACE_TILE_COUNT],
) -> Option<LabelAtlas> {
let (mut font_system, family, weight) = font_system(labels)?;
let mut swash_cache = SwashCache::new();
let mut texts: Vec<&str> = labels.iter().map(String::as_str).collect();
texts.extend(["N", "E", "S", "W"]);
let bitmaps: Vec<LabelBitmap> = texts
.into_iter()
.map(|text| {
rasterize_label(
&mut font_system,
&mut swash_cache,
text,
family,
weight,
)
})
.collect();
Some(pack_bitmaps(&bitmaps))
}
fn rasterize_label(
font_system: &mut FontSystem,
swash_cache: &mut SwashCache,
text: &str,
family: Option<&str>,
weight: Weight,
) -> LabelBitmap {
let attrs = match family {
Some(family) => Attrs::new().family(Family::Name(family)).weight(weight),
None => Attrs::new().family(Family::SansSerif).weight(weight),
};
let mut buffer = Buffer::new(font_system, Metrics::new(FONT_PX, LINE_PX));
buffer.set_size(font_system, None, None);
buffer.set_wrap(font_system, Wrap::None);
buffer.set_text(font_system, text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(font_system, false);
let logical_width = buffer
.layout_runs()
.map(|run| run.line_w)
.fold(0.0_f32, f32::max)
.ceil() as i32;
let mut ink_min = [0_i32, 0_i32];
let mut ink_max = [logical_width.max(1), LINE_PX.ceil() as i32];
buffer.draw(
font_system,
swash_cache,
Color::rgb(255, 255, 255),
|x, y, width, height, color| {
if color.a() == 0 {
return;
}
ink_min[0] = ink_min[0].min(x);
ink_min[1] = ink_min[1].min(y);
ink_max[0] = ink_max[0].max(x + width as i32);
ink_max[1] = ink_max[1].max(y + height as i32);
},
);
let origin_x = ink_min[0] - TILE_PADDING;
let origin_y = ink_min[1] - TILE_PADDING;
let width = (ink_max[0] - ink_min[0] + TILE_PADDING * 2).max(1) as u32;
let height = (ink_max[1] - ink_min[1] + TILE_PADDING * 2).max(1) as u32;
let mut pixels = vec![0_u8; (width * height) as usize];
buffer.draw(
font_system,
swash_cache,
Color::rgb(255, 255, 255),
|x, y, pixel_width, pixel_height, color| {
let alpha = color.a();
if alpha == 0 {
return;
}
for py in 0..pixel_height as i32 {
for px in 0..pixel_width as i32 {
let dst_x = x + px - origin_x;
let dst_y = y + py - origin_y;
if dst_x < 0 || dst_y < 0 || dst_x >= width as i32 || dst_y >= height as i32 {
continue;
}
let index = dst_y as usize * width as usize + dst_x as usize;
let current = pixels[index] as u16;
let incoming = alpha as u16;
pixels[index] = (incoming + current * (255 - incoming) / 255) as u8;
}
}
},
);
let pixels = embolden_alpha(&pixels, width, height);
LabelBitmap {
width,
height,
pixels,
}
}
fn embolden_alpha(source: &[u8], width: u32, height: u32) -> Vec<u8> {
let mut output = source.to_vec();
for y in 0..height as i32 {
for x in 0..width as i32 {
let alpha = source[y as usize * width as usize + x as usize];
if alpha == 0 {
continue;
}
for offset_y in -EMBOLDEN_RADIUS..=EMBOLDEN_RADIUS {
for offset_x in -EMBOLDEN_RADIUS..=EMBOLDEN_RADIUS {
let target_x = x + offset_x;
let target_y = y + offset_y;
if target_x < 0
|| target_y < 0
|| target_x >= width as i32
|| target_y >= height as i32
{
continue;
}
let index = target_y as usize * width as usize + target_x as usize;
output[index] = output[index].max(alpha);
}
}
}
}
output
}
#[cfg(not(target_arch = "wasm32"))]
fn font_system(
_labels: &[String; FACE_TILE_COUNT],
) -> Option<(FontSystem, Option<&'static str>, Weight)> {
Some((FontSystem::new(), None, Weight::SEMIBOLD))
}
#[cfg(target_arch = "wasm32")]
fn font_system(
_labels: &[String; FACE_TILE_COUNT],
) -> Option<(FontSystem, Option<&'static str>, Weight)> {
let script = crate::scene::text::web_font::primary_script();
let bytes = crate::scene::text::web_font::loaded(script)?;
let mut database = fontdb::Database::new();
database.load_font_data((*bytes).clone());
database.set_sans_serif_family(script.family());
Some((
FontSystem::new_with_locale_and_db("en-US".to_owned(), database),
Some(script.family()),
Weight::NORMAL,
))
}
fn pack_bitmaps(bitmaps: &[LabelBitmap]) -> LabelAtlas {
let mut pixels = vec![0_u8; (ATLAS_WIDTH * ATLAS_HEIGHT) as usize];
let mut tiles = [AtlasTile::default(); TILE_COUNT];
let mut cursor_x = TILE_GAP;
let mut cursor_y = TILE_GAP;
let mut row_height = 0_u32;
for (index, bitmap) in bitmaps.iter().enumerate().take(TILE_COUNT) {
if cursor_x + bitmap.width + TILE_GAP > ATLAS_WIDTH {
cursor_x = TILE_GAP;
cursor_y += row_height + TILE_GAP;
row_height = 0;
}
if cursor_y + bitmap.height + TILE_GAP > ATLAS_HEIGHT {
break;
}
for row in 0..bitmap.height as usize {
let src = row * bitmap.width as usize;
let dst = (cursor_y as usize + row) * ATLAS_WIDTH as usize + cursor_x as usize;
pixels[dst..dst + bitmap.width as usize]
.copy_from_slice(&bitmap.pixels[src..src + bitmap.width as usize]);
}
tiles[index] = AtlasTile {
uv_min: [
cursor_x as f32 / ATLAS_WIDTH as f32,
cursor_y as f32 / ATLAS_HEIGHT as f32,
],
uv_max: [
(cursor_x + bitmap.width) as f32 / ATLAS_WIDTH as f32,
(cursor_y + bitmap.height) as f32 / ATLAS_HEIGHT as f32,
],
aspect: bitmap.width as f32 / bitmap.height as f32,
};
cursor_x += bitmap.width + TILE_GAP;
row_height = row_height.max(bitmap.height);
}
LabelAtlas { pixels, tiles }
}
+9 -4
View File
@@ -395,6 +395,10 @@ pub fn tessellate_text_run(
let toks = tokenize_run(text);
let ttf_family = face.ttf_family();
let shaping_family = ttf_family.or_else(|| {
crate::scene::text::web_font::requires_shaping(text)
.then(|| crate::scene::text::web_font::primary_script().family())
});
// Render a stroke directly (LFF glyph) or per shaped contour (TTF), applying
// the run transform at the current pen position.
@@ -420,7 +424,7 @@ pub fn tessellate_text_run(
if seg.is_empty() {
return;
}
let family = ttf_family.unwrap_or("");
let family = shaping_family.unwrap_or("");
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(family, seg) {
for g in &run.glyphs {
emit_glyph(out, &g.strokes, *cursor_x);
@@ -454,12 +458,12 @@ pub fn tessellate_text_run(
// TTF shaping batches consecutive glyphs; any break (space, missing,
// decoration toggle, end of run) flushes the buffer first so pen
// positions stay correct for decorations.
if ttf_family.is_some() && !matches!(tok, Tok::Glyph(_)) {
if shaping_family.is_some() && !matches!(tok, Tok::Glyph(_) | Tok::Space) {
flush_ttf(&mut seg, &mut cursor_x, &mut out, &mut fill_tris);
}
match tok {
Tok::Glyph(c) => {
if ttf_family.is_some() {
if shaping_family.is_some() {
seg.push(*c);
} else {
match face.glyph(*c) {
@@ -475,6 +479,7 @@ pub fn tessellate_text_run(
}
}
}
Tok::Space if shaping_family.is_some() => seg.push(' '),
Tok::Space => cursor_x += face.word_spacing(),
Tok::Missing => cursor_x += 6.0 + face.letter_spacing() * tracking,
Tok::Deco(deco, op) => {
@@ -507,7 +512,7 @@ pub fn tessellate_text_run(
}
}
}
if ttf_family.is_some() {
if shaping_family.is_some() {
flush_ttf(&mut seg, &mut cursor_x, &mut out, &mut fill_tris);
}
+6 -2
View File
@@ -74,8 +74,12 @@ pub fn textfill() -> bool {
/// re-baked tiles.
pub fn set_textfill(on: bool) {
TEXTFILL.store(on, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut a) = text_atlas().lock() {
a.reset();
reset_font_entries();
}
pub fn reset_font_entries() {
if let Ok(mut atlas) = text_atlas().lock() {
atlas.reset();
}
}
+97 -6
View File
@@ -325,6 +325,9 @@ pub fn fallback_glyph(ch: char) -> Option<Arc<Glyph>> {
/// outline. (#141)
pub fn clear_fallback_cache() {
fallback_cache().lock().unwrap().clear();
shape_cache().lock().unwrap().clear();
#[cfg(target_arch = "wasm32")]
crate::scene::text::sdf_atlas::reset_font_entries();
}
/// Web: outline the glyph from the lazily-fetched per-script Noto subset that
@@ -334,8 +337,12 @@ pub fn clear_fallback_cache() {
fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let script = crate::scene::text::web_font::script_of(ch)?;
let bytes = crate::scene::text::web_font::request(script)?;
let face = ttf_parser::Face::parse(&bytes, 0).ok()?;
let gid = face.glyph_index(ch)?;
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
let (face, gid) = (0..face_count).find_map(|face_index| {
let face = ttf_parser::Face::parse(&bytes, face_index).ok()?;
let gid = face.glyph_index(ch)?;
Some((face, gid))
})?;
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
@@ -389,11 +396,95 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
None
}
/// Web: no system fonts → no shaping (TTF faces don't occur on the web anyway,
/// since system-font discovery is empty).
#[cfg(target_arch = "wasm32")]
fn build_shaped(_family: &str, _text: &str) -> Option<ShapedRun> {
None
fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping};
let mut fonts: Vec<Arc<Vec<u8>>> = Vec::new();
let mut waiting = false;
for script in text
.chars()
.filter_map(crate::scene::text::web_font::script_of)
{
match crate::scene::text::web_font::request(script) {
Some(bytes) => {
if !fonts.iter().any(|loaded| Arc::ptr_eq(loaded, &bytes)) {
fonts.push(bytes);
}
}
None => waiting = true,
}
}
if waiting || fonts.is_empty() {
return None;
}
let primary_face = ttf_parser::Face::parse(&fonts[0], 0).ok()?;
let upem = primary_face.units_per_em() as f32;
let cap = primary_face
.capital_height()
.filter(|height| *height > 0)
.map(|height| height as f32)
.unwrap_or(0.7 * upem);
let px_to_9 = CAP_UNITS * upem / (SHAPE_FS * cap);
let mut database = fontdb::Database::new();
for bytes in fonts {
database.load_font_data((*bytes).clone());
}
database.set_sans_serif_family(crate::scene::text::web_font::primary_script().family());
let mut font_system = FontSystem::new_with_locale_and_db(
crate::i18n::active_language_tag(),
database,
);
let attrs = Attrs::new().family(Family::SansSerif);
let mut buffer = Buffer::new(&mut font_system, Metrics::new(SHAPE_FS, SHAPE_FS));
buffer.set_size(&mut font_system, None, None);
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(&mut font_system, false);
let mut glyphs = Vec::new();
let mut advance = 0.0_f32;
for run in buffer.layout_runs() {
advance = advance.max(run.line_w * px_to_9);
for glyph in run.glyphs.iter() {
let face_index = font_system
.db_mut()
.face(glyph.font_id)
.map(|face| face.index)
.unwrap_or(0);
let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else {
continue;
};
let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else {
continue;
};
let scale = (SHAPE_FS / face.units_per_em() as f32) * px_to_9;
let pen_x = (glyph.x + SHAPE_FS * glyph.x_offset) * px_to_9;
let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9;
let mut flattener = OutlineFlattener::new(scale);
flattener.offset = [pen_x, pen_y];
face.outline_glyph(
ttf_parser::GlyphId(glyph.glyph_id),
&mut flattener,
);
flattener.flush();
if flattener.contours.is_empty() {
continue;
}
let fill_tris = triangulate_contours(&flattener.contours);
glyphs.push(PlacedGlyph {
strokes: flattener.contours,
fill_tris,
});
}
}
if advance <= 0.0 && glyphs.is_empty() {
None
} else {
Some(ShapedRun { glyphs, advance })
}
}
#[cfg(not(target_arch = "wasm32"))]
+164 -17
View File
@@ -1,17 +1,19 @@
//! Per-script web font store + lazy fetch (#141).
//! Shared web font store + per-script fetch (#141).
//!
//! The desktop build outlines glyphs for non-LFF scripts from the user's
//! installed system fonts (see `ttf_glyph::build_fallback`). The web build has
//! no system-font access, so it instead lazily fetches a per-script Noto subset
//! — served from `fonts/<script>.ttf`, one alphabet per file — the first time a
//! drawing uses that script, then outlines glyphs from it. Splitting per script
//! keeps each download small (Latin/Cyrillic/Greek ~50–100 KB; CJK loads only
//! when a CJK drawing is opened).
//! no system-font access, so startup fetches the active UI language's Noto
//! subset and shares those bytes with UI, drawing text, and navigation labels.
//! Every language file also carries the common Latin ranges; other scripts are
//! still fetched on demand when a drawing needs them. CJK remains split by
//! language because the same code point can require a different glyph form.
//!
//! The store and fetch are web-only; the desktop side keeps no-op stubs so the
//! shared call sites (`ttf_glyph`, the app message loop) compile unchanged.
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
/// A script we ship a Noto subset for. [`script_of`] maps a char to one.
///
@@ -51,11 +53,131 @@ impl Script {
Script::Korean => "fonts/korean.ttf",
}
}
pub fn family(self) -> &'static str {
match self {
Script::Chinese => "Noto Sans CJK SC",
Script::Japanese => "Noto Sans CJK JP",
Script::Korean => "Noto Sans CJK KR",
Script::Latin
| Script::Cyrillic
| Script::Greek
| Script::Arabic
| Script::Hebrew
| Script::Thai
| Script::Devanagari => "Noto Sans",
}
}
fn from_u8(value: u8) -> Self {
match value {
1 => Script::Cyrillic,
2 => Script::Greek,
3 => Script::Arabic,
4 => Script::Hebrew,
5 => Script::Thai,
6 => Script::Devanagari,
7 => Script::Chinese,
8 => Script::Japanese,
9 => Script::Korean,
_ => Script::Latin,
}
}
#[cfg(target_arch = "wasm32")]
fn as_u8(self) -> u8 {
match self {
Script::Latin => 0,
Script::Cyrillic => 1,
Script::Greek => 2,
Script::Arabic => 3,
Script::Hebrew => 4,
Script::Thai => 5,
Script::Devanagari => 6,
Script::Chinese => 7,
Script::Japanese => 8,
Script::Korean => 9,
}
}
}
/// Language used to render shared Han ideographs: 0 = Chinese, 1 = Japanese,
/// 2 = Korean. Set from the active document's code page.
static CJK_LANG: AtomicU8 = AtomicU8::new(0);
static PRIMARY_SCRIPT: AtomicU8 = AtomicU8::new(0);
static FONT_GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn generation() -> u64 {
FONT_GENERATION.load(Ordering::Relaxed)
}
fn bump_generation() {
FONT_GENERATION.fetch_add(1, Ordering::Relaxed);
}
#[cfg(target_arch = "wasm32")]
pub fn scripts_for_language_tag(language: &str) -> Vec<Script> {
let language = language
.split(['-', '_'])
.next()
.unwrap_or(language)
.to_ascii_lowercase();
let script = match language.as_str() {
"ar" | "fa" | "ur" => Some(Script::Arabic),
"el" => Some(Script::Greek),
"he" | "yi" => Some(Script::Hebrew),
"hi" | "mr" | "ne" => Some(Script::Devanagari),
"ja" => Some(Script::Japanese),
"ko" => Some(Script::Korean),
"ru" | "uk" | "bg" | "sr" => Some(Script::Cyrillic),
"th" => Some(Script::Thai),
"zh" => Some(Script::Chinese),
_ => None,
};
vec![script.unwrap_or(Script::Latin)]
}
#[cfg(target_arch = "wasm32")]
pub fn preload_language(language: &str) -> Script {
let script = scripts_for_language_tag(language)
.into_iter()
.next()
.unwrap_or(Script::Latin);
PRIMARY_SCRIPT.store(script.as_u8(), Ordering::Relaxed);
bump_generation();
let _ = imp::request(script);
script
}
pub fn primary_script() -> Script {
Script::from_u8(PRIMARY_SCRIPT.load(Ordering::Relaxed))
}
pub fn requires_shaping(text: &str) -> bool {
#[cfg(target_arch = "wasm32")]
{
return text.chars().any(|ch| {
matches!(
ch as u32,
0x0590..=0x05FF
| 0x0600..=0x06FF
| 0x0750..=0x077F
| 0x08A0..=0x08FF
| 0x0900..=0x097F
| 0x0E00..=0x0E7F
| 0xA8E0..=0xA8FF
| 0xFB1D..=0xFB4F
| 0xFB50..=0xFDFF
| 0xFE70..=0xFEFF
)
});
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = text;
false
}
}
#[cfg(target_arch = "wasm32")]
fn cjk_lang() -> Script {
@@ -94,7 +216,7 @@ pub fn script_of(ch: char) -> Option<Script> {
0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => {
Script::Arabic
}
0x0900..=0x097F => Script::Devanagari,
0x0900..=0x097F | 0xA8E0..=0xA8FF => Script::Devanagari,
// Hangul → always Korean; kana → always Japanese.
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7A3 => Script::Korean,
0x3040..=0x30FF | 0x31F0..=0x31FF => Script::Japanese,
@@ -155,11 +277,32 @@ mod imp {
/// Record a fetch result: `Some(bytes)` on success, `None` on failure.
pub fn insert(script: Script, bytes: Option<Vec<u8>>) {
let st = match bytes {
Some(b) => State::Loaded(Arc::new(b)),
None => State::Failed,
};
store().lock().unwrap().states.insert(script, st);
let mut store = store().lock().unwrap();
match bytes {
Some(bytes) => {
let bytes = Arc::new(bytes);
store.states.insert(script, State::Loaded(bytes.clone()));
if script != Script::Latin {
store
.states
.entry(Script::Latin)
.or_insert_with(|| State::Loaded(bytes));
}
}
None => {
store.states.insert(script, State::Failed);
}
}
drop(store);
super::bump_generation();
}
pub fn loaded(script: Script) -> Option<Arc<Vec<u8>>> {
let s = store().lock().unwrap();
match s.states.get(&script) {
Some(State::Loaded(bytes)) => Some(bytes.clone()),
_ => None,
}
}
/// Fetch a script font over HTTP from the same origin.
@@ -183,8 +326,7 @@ mod imp {
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::Script;
use std::sync::Arc;
use super::{Arc, Script};
// Kept for parity with the wasm impl; only the wasm path has a caller.
#[allow(dead_code)]
@@ -194,13 +336,18 @@ mod imp {
pub fn take_pending() -> Vec<Script> {
Vec::new()
}
pub fn insert(_script: Script, _bytes: Option<Vec<u8>>) {}
pub fn insert(_script: Script, _bytes: Option<Vec<u8>>) {
super::bump_generation();
}
pub fn loaded(_script: Script) -> Option<Arc<Vec<u8>>> {
None
}
pub async fn fetch(_script: Script) -> Result<Vec<u8>, String> {
Err("web only".into())
}
}
pub use imp::{fetch, insert, take_pending};
pub use imp::{fetch, insert, loaded, take_pending};
// `request` only has a caller in the wasm font-fallback path.
#[cfg(target_arch = "wasm32")]
pub use imp::request;
+5 -7
View File
@@ -250,8 +250,6 @@ impl shader::Primitive for Primitive {
viewport: &Viewport,
) {
let nav_prepare_started = iced::time::Instant::now();
let phys = viewport.physical_size();
let full_size = Size::new(phys.width, phys.height);
let scale = viewport.scale_factor() as f32;
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
@@ -300,7 +298,11 @@ impl shader::Primitive for Primitive {
(vp.screen_rect.height * bounds.height * scale).ceil().max(1.0) as u32,
);
inner.ensure_depth_texture(device, clip_size);
inner.viewcube.ensure_depth_texture(device, full_size);
let viewcube_side =
(crate::scene::VIEWCUBE_RENDER_PX.ceil() * scale).ceil().max(1.0) as u32;
inner
.viewcube
.ensure_depth_texture(device, Size::new(viewcube_side, viewcube_side));
// Compute the UV crop for this viewport. `screen_rect` is in
// normalized canvas units (0..1) but may extend negative or
// beyond 1 when the viewport hangs off the canvas. The on-
@@ -335,8 +337,6 @@ impl shader::Primitive for Primitive {
queue,
vp.cam_rotation,
vp.compass_rotation,
(vp.screen_rect.width * bounds.width) as u32,
(vp.screen_rect.height * bounds.height) as u32,
vp.hover_region,
self.viewcube_text_color,
);
@@ -974,8 +974,6 @@ impl shader::Primitive for Primitive {
queue,
vp.cam_rotation,
vp.compass_rotation,
(vp.screen_rect.width * bounds.width) as u32,
(vp.screen_rect.height * bounds.height) as u32,
vp.hover_region,
self.viewcube_text_color,
);
+17 -4
View File
@@ -1,4 +1,4 @@
// ViewCube shader — Phong lighting + hover highlight.
// ViewCube shader — flat surface fill, hover highlight, and edge lines.
struct Uniforms {
view_proj: mat4x4<f32>,
@@ -26,9 +26,22 @@ fn fs_main(in: VOut) -> @location(0) vec4<f32> {
let is_hovered = abs(in.region_f - u.hover_region.x) < 0.001 && u.hover_region.x >= 0.0;
var final_rgb = in.color;
if is_hovered {
let glow = vec3<f32>(0.68, 0.88, 1.0);
final_rgb = mix(final_rgb, glow, 0.60);
final_rgb = clamp(final_rgb * 1.30, vec3<f32>(0.0), vec3<f32>(1.0));
final_rgb = vec3<f32>(0.20, 0.72, 0.66);
}
return vec4<f32>(final_rgb, 1.0);
}
struct LineIn {
@location(0) pos: vec3<f32>,
}
@vertex
fn line_vs_main(in: LineIn) -> @builtin(position) vec4<f32> {
let rotated = (u.rotation * vec4<f32>(in.pos, 1.0)).xyz;
return u.view_proj * vec4<f32>(rotated, 1.0);
}
@fragment
fn line_fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(0.05, 0.08, 0.13, 1.0);
}
+44
View File
@@ -0,0 +1,44 @@
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
struct CompositeUniforms {
uv_scale: vec2<f32>,
_pad: vec2<f32>,
}
var<private> POSITIONS: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
);
var<private> UVS: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0),
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
);
@vertex
fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput {
var output: VertexOutput;
output.position = vec4<f32>(POSITIONS[index], 0.0, 1.0);
output.uv = UVS[index];
return output;
}
@group(0) @binding(0) var resolved_texture: texture_2d<f32>;
@group(0) @binding(1) var resolved_sampler: sampler;
@group(0) @binding(2) var<uniform> uniforms: CompositeUniforms;
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(resolved_texture, resolved_sampler, input.uv * uniforms.uv_scale);
}
+2 -2
View File
@@ -8,7 +8,7 @@ struct Uniforms {
@group(0) @binding(2) var tex: texture_2d<f32>;
struct VsIn {
@location(0) pos: vec2<f32>,
@location(0) pos: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
};
@@ -26,7 +26,7 @@ fn vs_main(input: VsIn) -> VsOut {
(input.pos.x / u.screen.x) * 2.0 - 1.0,
1.0 - (input.pos.y / u.screen.y) * 2.0
);
out.pos = vec4<f32>(ndc, 0.0, 1.0);
out.pos = vec4<f32>(ndc, input.pos.z, 1.0);
out.uv = input.uv;
out.color = input.color;
return out;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35 -14
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
# Regenerate the per-script Noto subsets served to the web build.
# Each script lives in its own TTF so a browser only fetches the alphabets a
# drawing actually uses. Source: Noto fonts (SIL OFL 1.1 — see OFL.txt).
# Regenerate the language fonts served to the web build.
# The active language file is shared by the UI, drawing text and navigation
# labels. Every file contains the common Latin ranges, so startup needs one
# request; drawings can still fetch an additional script when necessary.
# Source: Noto fonts (SIL OFL 1.1 — see OFL.txt).
#
# Requires: pip install --break-system-packages fonttools brotli
set -euo pipefail
@@ -10,37 +12,56 @@ sub() { python3 -m fontTools.subset "$1" --unicodes="$2" --output-file="$3" \
--no-hinting --desubroutinize --layout-features='*' --notdef-outline \
--name-IDs='*' --recalc-bounds 2>/dev/null; }
dl() { curl -sL -o "$1" "$2"; }
merge() { pyftmerge /tmp/NotoSans.ttf "$1" --output-file="$2" >/dev/null; }
collection() {
python3 - "$1" "$2" "$3" <<'PY'
import sys
from fontTools.ttLib import TTCollection, TTFont
output, latin, script = sys.argv[1:]
fonts = TTCollection()
fonts.fonts = [TTFont(latin), TTFont(script)]
fonts.save(output)
PY
}
# Latin / Cyrillic / Greek share one Noto source.
dl /tmp/NotoSans.ttf "$RAW/NotoSans/hinted/ttf/NotoSans-Regular.ttf"
sub /tmp/NotoSans.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+20A0-20BF,U+2122,U+2190-21FF,U+2200-22FF" latin.ttf
sub /tmp/NotoSans.ttf "U+0400-04FF,U+0500-052F,U+2DE0-2DFF,U+A640-A69F" cyrillic.ttf
sub /tmp/NotoSans.ttf "U+0370-03FF,U+1F00-1FFF" greek.ttf
sub /tmp/NotoSans.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0400-04FF,U+0500-052F,U+2DE0-2DFF,U+A640-A69F" cyrillic.ttf
sub /tmp/NotoSans.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0370-03FF,U+1F00-1FFF" greek.ttf
# One source per remaining script.
dl /tmp/NotoArabic.ttf "$RAW/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf"
sub /tmp/NotoArabic.ttf "U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF" arabic.ttf
merge /tmp/NotoArabic.ttf /tmp/NotoArabicMerged.ttf
sub /tmp/NotoArabicMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF" arabic.ttf
dl /tmp/NotoHebrew.ttf "$RAW/NotoSansHebrew/hinted/ttf/NotoSansHebrew-Regular.ttf"
sub /tmp/NotoHebrew.ttf "U+0590-05FF,U+FB1D-FB4F" hebrew.ttf
merge /tmp/NotoHebrew.ttf /tmp/NotoHebrewMerged.ttf
sub /tmp/NotoHebrewMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0590-05FF,U+FB1D-FB4F" hebrew.ttf
dl /tmp/NotoThai.ttf "$RAW/NotoSansThai/hinted/ttf/NotoSansThai-Regular.ttf"
sub /tmp/NotoThai.ttf "U+0E00-0E7F" thai.ttf
merge /tmp/NotoThai.ttf /tmp/NotoThaiMerged.ttf
sub /tmp/NotoThaiMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0E00-0E7F" thai.ttf
dl /tmp/NotoDeva.ttf "$RAW/NotoSansDevanagari/hinted/ttf/NotoSansDevanagari-Regular.ttf"
sub /tmp/NotoDeva.ttf "U+0900-097F,U+A8E0-A8FF" devanagari.ttf
merge /tmp/NotoDeva.ttf /tmp/NotoDevaMerged.ttf
sub /tmp/NotoDevaMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0900-097F,U+A8E0-A8FF" devanagari.ttf
# CJK comes from the noto-cjk repo (CFF/OTF; ttf-parser reads CFF outlines) and
# is split by language: Chinese, Japanese and Korean each get their own file.
# Han ideographs share code points but differ in glyph shape, so each language
# ships its own. These are the heavy ones — lazy-loaded per language only when a
# drawing in that language is opened.
# ships its own. These are the heavy ones and remain split so startup only loads
# the active language.
CJK="https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF"
dl /tmp/NotoSC.otf "$CJK/SimplifiedChinese/NotoSansCJKsc-Regular.otf"
sub /tmp/NotoSC.otf "U+3000-303F,U+4E00-9FFF,U+FF00-FFEF" chinese.ttf
sub /tmp/NotoSC.otf "U+3000-303F,U+4E00-9FFF,U+FF00-FFEF" /tmp/chinese.otf
collection chinese.ttf latin.ttf /tmp/chinese.otf
dl /tmp/NotoJP.otf "$CJK/Japanese/NotoSansCJKjp-Regular.otf"
sub /tmp/NotoJP.otf "U+3000-303F,U+3040-30FF,U+31F0-31FF,U+4E00-9FFF,U+FF00-FFEF" japanese.ttf
sub /tmp/NotoJP.otf "U+3000-303F,U+3040-30FF,U+31F0-31FF,U+4E00-9FFF,U+FF00-FFEF" /tmp/japanese.otf
collection japanese.ttf latin.ttf /tmp/japanese.otf
dl /tmp/NotoKR.otf "$CJK/Korean/NotoSansCJKkr-Regular.otf"
sub /tmp/NotoKR.otf "U+1100-11FF,U+3130-318F,U+AC00-D7A3" korean.ttf
sub /tmp/NotoKR.otf "U+1100-11FF,U+3130-318F,U+AC00-D7A3" /tmp/korean.otf
collection korean.ttf latin.ttf /tmp/korean.otf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.