feat: render programmable 3d scenes cameras and picking
This commit is contained in:
@@ -142,9 +142,11 @@ 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. Independent 2D scenes are described below; Scene3D/Viewport3D are still pending. Page.mode remains a compatibility 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; 3D support is described below. 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.
|
||||
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. 3D support is described below. See examples/scene-viewports.endor.
|
||||
|
||||
3D basics are now implemented: Scene3D/Group3D, Box3D, immutable Geometry3D made from Vec3/Triangle3D ObjectLists, Mesh3D, DirectionalLight, and finite Viewport3D. PerspectiveCamera and OrthographicCamera expose x/y/z, targetX/Y/Z, upX/Y/Z, near/far, fov or height; viewport aspect is derived separately. New viewports have independent cameras and OrbitController; explicit camera assignment shares a camera. A controller can be replaced with an Endor object defining update(camera,gesture), or disabled by assigning "none". Native scene-orbit/scene-pan/scene-zoom events carry viewport Mount/frame identities. Mesh/group translate and rotate use native arithmetic; camera.translate moves position and target. This avoids the fixed Aster version's unsupported numeric arithmetic expressions. Mount.pick3D(x,y) returns nullable RayHit3D with managed mesh and world intersection data; pointer click Event.hit carries the same result. Rendering uses frustum clipping and a depth buffer in a bounded cached surface. World-space interactive UI panels and complete nesting budgets remain pending. See examples/scene3d.endor and docs/scene-architecture.md.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -665,3 +665,94 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
|
||||
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: 400.,
|
||||
height: 300.,
|
||||
}),
|
||||
..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##"
|
||||
class PanOnly {
|
||||
function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) }
|
||||
}
|
||||
view = Viewport3D(200,100,Scene3D(Box3D(2,2,2,"#CC4455")))
|
||||
page = Page()
|
||||
page.render(view)
|
||||
"##;
|
||||
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
||||
let current = namespace.read().unwrap();
|
||||
let frame = ¤t.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.scene3d_viewport)
|
||||
.unwrap()
|
||||
.id,
|
||||
kind: kind.into(),
|
||||
delta: Some(delta),
|
||||
point,
|
||||
press_point: None,
|
||||
tab_drop: None,
|
||||
screen_position: None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let event_path = "/v1/namespaces/app.main/events";
|
||||
let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.]));
|
||||
let response = client.request("POST", event_path, &zoom);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(client.request("POST", event_path, &zoom).0, 409);
|
||||
let value = |expression: &str| {
|
||||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
};
|
||||
assert_eq!(value("view.camera.z"), "2.5");
|
||||
let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None));
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let x = value("view.camera.x").parse::<f32>().unwrap();
|
||||
assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001);
|
||||
value("view.camera = OrthographicCamera()");
|
||||
let response = client.request(
|
||||
"POST",
|
||||
event_path,
|
||||
&gesture("scene-zoom", [2., 0.], Some([100., 50.])),
|
||||
);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(value("view.camera.height"), "2.5");
|
||||
value("view.controller = PanOnly()");
|
||||
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(value("view.camera.x"), "7");
|
||||
let response = client.request(
|
||||
"POST",
|
||||
event_path,
|
||||
&gesture("scene-zoom", [2., 0.], Some([250., 50.])),
|
||||
);
|
||||
assert_eq!(response.0, 400);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
value("view.controller = \"none\"");
|
||||
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
|
||||
assert_eq!(response.0, 409);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
}
|
||||
|
||||
@@ -340,6 +340,9 @@ impl Worker {
|
||||
| "page-zoom"
|
||||
| "viewport-pan"
|
||||
| "viewport-zoom"
|
||||
| "scene-orbit"
|
||||
| "scene-pan"
|
||||
| "scene-zoom"
|
||||
| "tab-activate"
|
||||
| "tab-close"
|
||||
| "tab-detach"
|
||||
@@ -439,9 +442,15 @@ impl Worker {
|
||||
} else {
|
||||
!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())
|
||||
&& if input.kind.starts_with("viewport-") {
|
||||
mount.canvas_viewport
|
||||
} else if input.kind.starts_with("scene-") {
|
||||
mount.scene3d_viewport && mount.camera_controls
|
||||
} else if input.kind == "scroll" {
|
||||
mount.scroll.is_some()
|
||||
} else {
|
||||
mount.interactive
|
||||
}
|
||||
})
|
||||
}
|
||||
|| self.repl.as_ref().is_none_or(|(context, runtime)| {
|
||||
@@ -463,7 +472,7 @@ impl Worker {
|
||||
"camera input is unavailable in this Page mode",
|
||||
));
|
||||
}
|
||||
if input.kind == "viewport-zoom"
|
||||
if matches!(input.kind.as_str(), "viewport-zoom" | "scene-zoom")
|
||||
&& !frame.mounts.iter().any(|mount| {
|
||||
mount.id == input.mount
|
||||
&& input.point.is_some_and(|point| {
|
||||
|
||||
Reference in New Issue
Block a user