fix(wasm): make the web build actually run in the browser

With the wasm bundle building, get it rendering in a browser:

- Enable iced `webgl` so wgpu uses WebGL2 where WebGPU is absent
  (e.g. Firefox by default), and `fira-sans` so UI text has an
  embedded font (the web has no system fonts) — without it every
  label was blank.
- Replace `std::time::Instant` with `iced::time::Instant` (web-safe);
  `Instant::now()` panics ("time not implemented") on wasm and crashed
  at boot via the command line.
- Skip the batched-hatch pipeline on wasm: its bind group needs
  vertex-stage storage buffers, which WebGL2 lacks (validation error
  on "New drawing"). Fields become Option; upload/draw guard on them.
  Hatches don't draw on the web for now; everything else does.
- Fix the trunk feature flag (`data-cargo-no-default-features`) and
  ignore the `dist/` build output.

Native build + 75 tests unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-16 17:53:56 +03:00
commit c49ad887b8
11 changed files with 55 additions and 14 deletions

3
.gitignore vendored
View file

@ -3,6 +3,9 @@
debug
target
# Trunk web build output (wasm/js bundle)
dist
# These are backup files generated by rustfmt
**/*.rs.bk

10
Cargo.lock generated
View file

@ -6336,6 +6336,7 @@ dependencies = [
"thiserror 2.0.18",
"wgpu-core-deps-apple",
"wgpu-core-deps-emscripten",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-types",
@ -6359,6 +6360,15 @@ dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-wasm"
version = "27.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "27.0.0"

View file

@ -82,3 +82,8 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
# Browsers without WebGPU (e.g. Firefox by default) need wgpu's WebGL2 backend;
# `webgl` enables it. `fira-sans` embeds the default UI font — the web has no
# system fonts, so without it all iced text (ribbon labels, command line) is
# blank. Features merge with the main `iced` dependency.
iced = { version = "0.14", features = ["webgl", "fira-sans"] }

View file

@ -8,6 +8,9 @@
[build]
target = "index.html"
# Note: the `solid3d` feature is disabled via `data-cargo-no-default-features`
# on the rust <link> in index.html (truck-meshalgo → lzma-sys C deps cannot
# cross-compile to wasm).
[serve]
# Cross-origin isolation headers enable SharedArrayBuffer, which wasm threads

View file

@ -10,7 +10,7 @@
</style>
<!-- Trunk builds the wasm32 binary without the `solid3d` feature (no 3-D
solid kernel / vtkio C deps on the web). See issue #45. -->
<link data-trunk rel="rust" data-bin="OpenCADStudio" data-no-default-features />
<link data-trunk rel="rust" data-bin="OpenCADStudio" data-cargo-no-default-features />
</head>
<body></body>
</html>

View file

@ -41,7 +41,7 @@ pub struct GripHover {
pub handle: acadrust::Handle,
pub grip_id: usize,
pub screen: iced::Point,
pub started: std::time::Instant,
pub started: iced::time::Instant,
}
/// Open multi-functional-grip popup state.

View file

@ -7711,7 +7711,7 @@ impl OpenCADStudio {
handle,
grip_id,
screen: p,
started: std::time::Instant::now(),
started: iced::time::Instant::now(),
});
self.grip_popup = None;
} else if let Some(h) = self.grip_hover.as_mut() {

View file

@ -61,7 +61,7 @@ pub async fn open_path_with_phase(
let path2 = path.clone();
let phase2 = phase.clone();
let (doc, caches) = std::thread::spawn(move || -> Result<_, String> {
use std::time::Instant;
use iced::time::Instant;
phase2.store(PHASE_PARSING, Ordering::Relaxed);
let t_parse = Instant::now();
let mut doc = load_file(&path2)?;

View file

@ -1710,7 +1710,7 @@ impl Scene {
Some([cx - w * margin, cy - h * margin, cx + w * margin, cy + h * margin])
};
let block = self.model_space_block_handle();
let t_tess = std::time::Instant::now();
let t_tess = iced::time::Instant::now();
let arc = Arc::new(self.wires_for_block_culled(block, tess_region, wpp, None, None));
self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0);
self.last_tess_wires.set(arc.len());
@ -1739,7 +1739,7 @@ impl Scene {
}
}
let layout_block = self.current_layout_block_handle();
let t_tess = std::time::Instant::now();
let t_tess = iced::time::Instant::now();
let mut wires = self.wires_for_block(layout_block);
self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0);
self.last_tess_wires.set(wires.len());

View file

@ -35,8 +35,9 @@ pub struct Pipeline {
hatch_pipeline: wgpu::RenderPipeline,
/// Phase 4-B — single-draw batched hatch pipeline. Per-instance
/// data lives in storage buffers; one draw call covers every
/// hatch in the frame.
hatch_batched_pipeline: wgpu::RenderPipeline,
/// hatch in the frame. `None` on WebGL2 (wasm), which lacks
/// vertex-stage storage buffers.
hatch_batched_pipeline: Option<wgpu::RenderPipeline>,
image_pipeline: wgpu::RenderPipeline,
mesh_pipeline: wgpu::RenderPipeline,
/// Wireframe variant of the mesh pipeline (LineList topology, same
@ -56,8 +57,8 @@ pub struct Pipeline {
uniform_bind_group: wgpu::BindGroup,
hatch_bgl1: wgpu::BindGroupLayout,
/// Group-1 layout for the batched hatch pipeline (storage buffers
/// for instances / boundary / families / dashes).
hatch_batched_bgl1: wgpu::BindGroupLayout,
/// for instances / boundary / families / dashes). `None` on WebGL2.
hatch_batched_bgl1: Option<wgpu::BindGroupLayout>,
image_bgl1: wgpu::BindGroupLayout,
depth_texture_size: Size<u32>,
depth_view: wgpu::TextureView,
@ -355,6 +356,16 @@ impl Pipeline {
});
// ── Hatch batched pipeline (Phase 4-B) ─────────────────────────────
// WebGL2 (the wasm fallback backend) has no vertex-stage storage
// buffers, which this pipeline's bind group requires. Skip it on wasm
// and run without the batched-hatch optimization.
#[cfg(target_arch = "wasm32")]
let (hatch_batched_bgl1, hatch_batched_pipeline): (
Option<wgpu::BindGroupLayout>,
Option<wgpu::RenderPipeline>,
) = (None, None);
#[cfg(not(target_arch = "wasm32"))]
let (hatch_batched_bgl1, hatch_batched_pipeline) = {
let hatch_batched_bgl1 = hatch_batched_gpu::HatchBatchedGpu::bind_group_layout(device);
let hatch_batched_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hatch_batched.pipeline_layout"),
@ -406,6 +417,8 @@ impl Pipeline {
multiview: None,
cache: None,
});
(Some(hatch_batched_bgl1), Some(hatch_batched_pipeline))
};
// ── Mesh pipeline ──────────────────────────────────────────────────
let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@ -1152,10 +1165,15 @@ impl Pipeline {
}
pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) {
// No batched pipeline (WebGL2) → nothing to upload.
let Some(bgl1) = &self.hatch_batched_bgl1 else {
self.gpu_hatch_batched = None;
return;
};
let renderable: Vec<HatchModel> =
hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect();
self.gpu_hatch_batched =
hatch_batched_gpu::HatchBatchedGpu::build(device, &self.hatch_batched_bgl1, &renderable);
hatch_batched_gpu::HatchBatchedGpu::build(device, bgl1, &renderable);
}
pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) {
@ -1257,8 +1275,10 @@ impl Pipeline {
// by `compute_hatch_lod`). Per-hatch viewport scissor
// (paper-space MSPACE) isn't ported to the batched path
// yet — follow-up if it shows up as a visual issue.
if let Some(batch) = &self.gpu_hatch_batched {
pass.set_pipeline(&self.hatch_batched_pipeline);
if let (Some(batch), Some(pipeline)) =
(&self.gpu_hatch_batched, &self.hatch_batched_pipeline)
{
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_bind_group(1, &batch.bind_group, &[]);
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));

View file

@ -1,6 +1,6 @@
//! OpenCADStudio-style command line — bottom panel with input and history
use std::time::Instant;
use iced::time::Instant;
use crate::app::Message;
use iced::widget::{button, column, container, opaque, row, scrollable, text, text_input};