feat: separate 2d scenes viewports and explicit cameras

This commit is contained in:
2026-11-10 22:42:56 +08:00
parent 32d7ada3ab
commit 040b779f5f
29 changed files with 1309 additions and 70 deletions
+3 -1
View File
@@ -142,7 +142,9 @@ ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or
ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing.
Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Scene2D/CanvasViewport/Camera2D and Scene3D/Viewport3D are still pending; Page.mode remains a transitional API.
Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Independent 2D scenes are described below; Scene3D/Viewport3D are still pending. Page.mode remains a compatibility API.
Independent 2D scenes are now implemented: Scene2D(children...) stores world content at child x/y; CanvasViewport(width,height,scene) is a finite layout box with its own Camera2D(). Camera2D(x=0,y=0,zoom=1) fields are ordinary managed attributes; zoom is 0.01–100. Sharing a scene does not share its camera; assigning second.camera=first.camera does. Scene content may contain layout containers and nested finite viewports; Overlay siblings stay fixed. Mount.fitView() fits finite direct scene-child bounds using that occurrence's allocation. Native wheel/pinch/space or middle drag targets the innermost viewport; drag retains its initial target. viewport-pan/viewport-zoom carry the viewport Mount and current frame identities, with signed logical page coordinates. Pan delta is measured before the viewport projection; zoom delta is [factor,0] with an in-content pointer anchor. Worker validation rejects stale/invalid input before invoking the VM. Draw and hit transforms compose consistently while layout geometry and measurement caches remain in world coordinates. ScrollView is a nominal ScrollBlock subtype. The default page uses these APIs; old Page.mode/panX/panY/zoom remain for compatibility. Scene3D/Viewport3D are still pending. See examples/scene-viewports.endor.
Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below.
+84
View File
@@ -581,3 +581,87 @@ page.render(button)
assert_eq!(response.0, 400);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 600.0,
height: 400.0,
}),
..NamespaceState::default()
}));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"
content = Button("inside")
content.x = 40
content.y = 20
scene = Scene2D(content)
view = CanvasViewport(200, 100, scene)
view.borderLeft = 10
page = Page()
page.render(Column(20, 0, view))
"#;
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame
.mounts
.iter()
.find(|mount| mount.canvas_viewport)
.unwrap()
.id,
kind: kind.into(),
delta: Some(delta),
point,
press_point: None,
screen_position: None,
tab_drop: None,
})
.unwrap()
};
let path = "/v1/namespaces/app.main/events";
let pan = camera("viewport-pan", [20.0, -10.0], None);
let response = client.request("POST", path, &pan);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(client.request("POST", path, &pan).0, 409);
let response = client.request(
"POST",
path,
&camera("viewport-zoom", [2.0, 0.0], Some([80.0, 60.0])),
);
assert_eq!(response.0, 200, "{}", response.2);
for (expression, expected) in [
("view.camera.x", "45"),
("view.camera.y", "10"),
("view.camera.zoom", "2"),
] {
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
expected
);
}
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
let response = client.request(
"POST",
path,
&camera("viewport-zoom", [2.0, 0.0], Some(point)),
);
assert_eq!(response.0, 400, "{}", response.2);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
}
+23
View File
@@ -338,6 +338,8 @@ impl Worker {
| "scroll"
| "page-pan"
| "page-zoom"
| "viewport-pan"
| "viewport-zoom"
| "tab-activate"
| "tab-close"
| "tab-detach"
@@ -438,6 +440,7 @@ impl Worker {
!frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& (mount.interactive
|| input.kind.starts_with("viewport-") && mount.canvas_viewport
|| input.kind == "scroll" && mount.scroll.is_some())
})
}
@@ -460,6 +463,26 @@ impl Worker {
"camera input is unavailable in this Page mode",
));
}
if input.kind == "viewport-zoom"
&& !frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& input.point.is_some_and(|point| {
let point = crate::geometry::Point::new(point[0], point[1]);
mount.contains(point)
&& mount.viewport_clip.is_some_and(|clip| {
point.x >= clip[0]
&& point.y >= clip[1]
&& point.x < clip[0] + clip[2]
&& point.y < clip[1] + clip[3]
})
})
})
{
return RequestProgress::Complete(error_response(
StatusCode(400),
"zoom anchor is outside content viewport",
));
}
Some(input)
} else {
None