Skip to main content

moss_core/render/
model.rs

1//! 3D model-viewer embed synthesizer.
2//!
3//! Receives a [`TitleParams`] (Stage 2 dispatcher already parsed it), the
4//! source URL, and an [`AssetSnapshot`]. Emits final `<model-viewer>` HTML —
5//! preserving the shape moss-core's `ModelViewerRenderer` used to emit
6//! before Phase 0's Stage 1 migration.
7//!
8//! Source byte shape (pre-Phase-0, see commit `689d975e9^`,
9//! `crates/moss-core/src/resolve/embed_renderer.rs:723`):
10//!
11//! ```text
12//! <model-viewer class="moss-embed" data-type="3d"{data-width} src="{src}"
13//!   camera-controls auto-rotate touch-action="pan-y" loading="lazy"{style}>
14//! </model-viewer>
15//! ```
16//!
17//! `{data-width}` is ` data-width="VALUE"` (with leading space) when the
18//! wrapper width is set (`body | wide | page | screen`), empty otherwise.
19//! `{style}` is ` style="width:W"` or ` style="width:W;height:H"` when
20//! sizing params are present, empty otherwise.
21//!
22//! Stage 2 reads the boolean flags (`camera-controls`, `auto-rotate`, `ar`)
23//! from [`TitleParams`] — when the wikilink grammar surfaces them as
24//! `param=true` they appear as bare attributes on the element.
25
26use crate::asset_snapshot::AssetSnapshot;
27use crate::resolve::embed_renderer::html_escape_attr;
28use crate::resolve::title_params::TitleParams;
29
30/// CSS class the pre-Phase-0 renderer placed on the `<model-viewer>` element.
31/// Mirrors `CLASS_EMBED` in `crates/moss-core/src/resolve/embed_renderer.rs`.
32const CLASS_EMBED: &str = "moss-embed";
33
34/// Synthesize 3D model-viewer embed HTML for `Tag::Link` with `moss:kind=3d` title.
35///
36/// Byte shape matches the pre-Phase-0 `ModelViewerRenderer::render` emission
37/// in moss-core: `camera-controls` and `auto-rotate` are emitted by default
38/// (suppress with `param=false`), and `ar` is opt-in (`ar=true`). This
39/// preserves the implicit defaults of existing `![[scene.glb]]` wikilinks
40/// while still allowing explicit override via title params.
41#[allow(unused_variables)]
42pub fn synthesize_model_html(
43    params: &TitleParams,
44    src: &str,
45    assets: &AssetSnapshot,
46) -> String {
47    let data_width = match params.get("data-width") {
48        Some(w) => format!(r#" data-width="{}""#, html_escape_attr(w)),
49        None => String::new(),
50    };
51
52    let flags = collect_flag_attrs(params);
53    let style = collect_style_attr(params);
54
55    format!(
56        r#"<model-viewer class="{class}" data-type="3d"{data_width} src="{src}"{flags} touch-action="pan-y" loading="lazy"{style}></model-viewer>"#,
57        class = CLASS_EMBED,
58        data_width = data_width,
59        src = html_escape_attr(src),
60        flags = flags,
61        style = style,
62    )
63}
64
65/// Concatenate the boolean-flag attribute fragment.
66///
67/// `camera-controls` and `auto-rotate` default ON to match the pre-Phase-0
68/// `ModelViewerRenderer` byte shape — they emit unless `param=false` opts
69/// out. `ar` defaults OFF (opt-in only). Each flag becomes a bare HTML
70/// attribute (`camera-controls`, not `camera-controls="true"`).
71fn collect_flag_attrs(params: &TitleParams) -> String {
72    // (name, default_on)
73    const FLAGS: &[(&str, bool)] = &[
74        ("camera-controls", true),
75        ("auto-rotate", true),
76        ("ar", false),
77    ];
78    let mut out = String::new();
79    for (flag, default_on) in FLAGS {
80        let emit = match params.get(flag) {
81            Some("true") => true,
82            Some("false") => false,
83            _ => *default_on,
84        };
85        if emit {
86            out.push(' ');
87            out.push_str(flag);
88        }
89    }
90    out
91}
92
93/// Build the inline `style="width:...;height:..."` fragment.
94///
95/// Unlike iframe/video, `<model-viewer>` consumes CSS length values via
96/// inline style (the element ignores HTML `width=`/`height=` attributes).
97/// Mirrors `model_viewer_style` in the pre-Phase-0 renderer.
98fn collect_style_attr(params: &TitleParams) -> String {
99    let w = params.get("width");
100    let h = params.get("height");
101    match (w, h) {
102        (Some(w), Some(h)) => format!(
103            r#" style="width:{};height:{}""#,
104            html_escape_attr(w),
105            html_escape_attr(h),
106        ),
107        (Some(w), None) => format!(r#" style="width:{}""#, html_escape_attr(w)),
108        (None, Some(h)) => format!(r#" style="height:{}""#, html_escape_attr(h)),
109        (None, None) => String::new(),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn empty_snapshot() -> AssetSnapshot {
118        AssetSnapshot::new()
119    }
120
121    fn params_with(kvs: &[(&str, &str)]) -> TitleParams {
122        let mut p = TitleParams::default();
123        for (k, v) in kvs {
124            p.insert(*k, *v);
125        }
126        p
127    }
128
129    #[test]
130    fn model_basic_shape() {
131        let p = params_with(&[("kind", "3d")]);
132        let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
133        assert!(out.contains("<model-viewer"), "got: {}", out);
134        assert!(out.contains(r#"src="scene.glb""#));
135    }
136
137    #[test]
138    fn model_with_camera_controls() {
139        let p = params_with(&[("kind", "3d"), ("camera-controls", "true")]);
140        let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
141        assert!(out.contains("camera-controls"));
142    }
143
144    #[test]
145    fn model_with_auto_rotate() {
146        let p = params_with(&[("kind", "3d"), ("auto-rotate", "true")]);
147        let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
148        assert!(out.contains("auto-rotate"));
149    }
150
151    #[test]
152    fn model_with_ar() {
153        let p = params_with(&[("kind", "3d"), ("ar", "true")]);
154        let out = synthesize_model_html(&p, "scene.glb", &empty_snapshot());
155        assert!(out.contains(" ar"));
156    }
157
158    #[test]
159    fn model_escapes_url() {
160        let p = params_with(&[("kind", "3d")]);
161        let out = synthesize_model_html(&p, r#"scene with "spaces".glb"#, &empty_snapshot());
162        // The src attribute must HTML-escape quotes.
163        assert!(
164            !out.contains(r#"src="scene with """#),
165            "raw quote not escaped, got: {}",
166            out
167        );
168    }
169
170    // --- Additional byte-shape pins (preserve pre-Phase-0 ModelViewerRenderer shape) ---
171
172    #[test]
173    fn model_emits_moss_embed_class_and_data_type() {
174        let p = params_with(&[("kind", "3d")]);
175        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
176        assert!(
177            out.contains(r#"class="moss-embed" data-type="3d""#),
178            "got: {}",
179            out
180        );
181    }
182
183    #[test]
184    fn model_emits_touch_action_and_loading() {
185        // `touch-action="pan-y"` and `loading="lazy"` are always emitted —
186        // they preserve the pre-Phase-0 byte shape.
187        let p = params_with(&[("kind", "3d")]);
188        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
189        assert!(out.contains(r#"touch-action="pan-y""#), "got: {}", out);
190        assert!(out.contains(r#"loading="lazy""#), "got: {}", out);
191    }
192
193    #[test]
194    fn model_emits_width_style() {
195        // Stage 1's `model_viewer_extra_params` folds `|400` aliases into
196        // `width=400px`; Stage 2 re-projects to inline CSS.
197        let p = params_with(&[("kind", "3d"), ("width", "400px")]);
198        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
199        assert!(out.contains(r#"style="width:400px""#), "got: {}", out);
200    }
201
202    #[test]
203    fn model_emits_width_and_height_style() {
204        let p = params_with(&[("kind", "3d"), ("width", "400px"), ("height", "400px")]);
205        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
206        assert!(
207            out.contains(r#"style="width:400px;height:400px""#),
208            "got: {}",
209            out
210        );
211    }
212
213    #[test]
214    fn model_emits_data_width_wrapper_attr() {
215        // Wrapper-width tokens (`body | wide | page | screen`) ride the
216        // `data-width=` attribute, matching `width_attr` in moss-core common.
217        let p = params_with(&[("kind", "3d"), ("data-width", "wide")]);
218        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
219        assert!(out.contains(r#"data-width="wide""#), "got: {}", out);
220    }
221
222    #[test]
223    fn model_suppresses_default_flag_when_param_false() {
224        // `camera-controls` is on by default; `param=false` suppresses it.
225        let p = params_with(&[("kind", "3d"), ("camera-controls", "false")]);
226        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
227        assert!(!out.contains("camera-controls"), "got: {}", out);
228        // auto-rotate still emits (its default also true, but not suppressed).
229        assert!(out.contains("auto-rotate"), "got: {}", out);
230    }
231
232    #[test]
233    fn model_defaults_emit_camera_controls_and_auto_rotate() {
234        // Pre-Phase-0 parity: bare `![[scene.glb]]` (no flag params) must
235        // still emit camera-controls and auto-rotate. `ar` stays opt-in.
236        let p = params_with(&[("kind", "3d")]);
237        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
238        assert!(out.contains("camera-controls"), "got: {}", out);
239        assert!(out.contains("auto-rotate"), "got: {}", out);
240        assert!(!out.contains(" ar"), "ar must stay opt-in, got: {}", out);
241    }
242
243    #[test]
244    fn model_closes_tag() {
245        let p = params_with(&[("kind", "3d")]);
246        let out = synthesize_model_html(&p, "x.glb", &empty_snapshot());
247        assert!(out.ends_with("></model-viewer>"), "got: {}", out);
248    }
249}