diff --git a/common/src/app/common/geom/align.cljc b/common/src/app/common/geom/align.cljc index e7b8bcc518..2d27e74c79 100644 --- a/common/src/app/common/geom/align.cljc +++ b/common/src/app/common/geom/align.cljc @@ -124,33 +124,51 @@ (defn adjust-to-viewport ([viewport srect] (adjust-to-viewport viewport srect nil)) - ([viewport srect {:keys [padding] :or {padding 0}}] + ([viewport srect {:keys [padding min-zoom] :or {padding 0 min-zoom nil}}] (let [gprop (/ (:width viewport) (:height viewport)) - srect (-> srect - (update :x #(- % padding)) - (update :y #(- % padding)) - (update :width #(+ % padding padding)) - (update :height #(+ % padding padding))) - width (:width srect) - height (:height srect) - lprop (/ width height)] - (cond - (> gprop lprop) - (let [width' (* (/ width lprop) gprop) - padding (/ (- width' width) 2)] - (-> srect - (update :x #(- % padding)) - (assoc :width width') - (grc/update-rect :position))) + srect-padded (-> srect + (update :x #(- % padding)) + (update :y #(- % padding)) + (update :width #(+ % padding padding)) + (update :height #(+ % padding padding))) + width (:width srect-padded) + height (:height srect-padded) + lprop (/ width height) + adjusted-rect + (cond + (> gprop lprop) + (let [width' (* (/ width lprop) gprop) + padding (/ (- width' width) 2)] + (-> srect-padded + (update :x #(- % padding)) + (assoc :width width') + (grc/update-rect :position))) - (< gprop lprop) - (let [height' (/ (* height lprop) gprop) - padding (/ (- height' height) 2)] - (-> srect - (update :y #(- % padding)) - (assoc :height height') - (grc/update-rect :position))) + (< gprop lprop) + (let [height' (/ (* height lprop) gprop) + padding (/ (- height' height) 2)] + (-> srect-padded + (update :y #(- % padding)) + (assoc :height height') + (grc/update-rect :position))) - :else - (grc/update-rect srect :position))))) + :else + (grc/update-rect srect-padded :position))] + ;; If min-zoom is specified and the resulting zoom would be below it, + ;; return a rect with the original top-left corner centered in the viewport + ;; instead of using the aspect-ratio-adjusted rect (which can push coords + ;; extremely far with extreme aspect ratios). + (if (and (some? min-zoom) + (< (/ (:width viewport) (:width adjusted-rect)) min-zoom)) + (let [anchor-x (:x srect) + anchor-y (:y srect) + vbox-width (/ (:width viewport) min-zoom) + vbox-height (/ (:height viewport) min-zoom)] + (-> adjusted-rect + (assoc :x (- anchor-x (/ vbox-width 2)) + :y (- anchor-y (/ vbox-height 2)) + :width vbox-width + :height vbox-height) + (grc/update-rect :position))) + adjusted-rect)))) diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index 79da7ba477..2687bb9113 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -51,7 +51,7 @@ (or (> (:width srect) width) (> (:height srect) height)) - (let [srect (gal/adjust-to-viewport size srect {:padding 40}) + (let [srect (gal/adjust-to-viewport size srect {:padding 40 :min-zoom 0.01}) zoom (/ (:width size) (:width srect))] (-> local diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index fbdd24a344..33f1846407 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -97,7 +97,7 @@ state (update state :workspace-local (fn [{:keys [vport] :as local}] - (let [srect (gal/adjust-to-viewport vport srect {:padding 160}) + (let [srect (gal/adjust-to-viewport vport srect {:padding 160 :min-zoom 0.01}) zoom (/ (:width vport) (:width srect))] (-> local (assoc :zoom zoom) @@ -118,7 +118,7 @@ (gsh/shapes->rect))] (update state :workspace-local (fn [{:keys [vport] :as local}] - (let [srect (gal/adjust-to-viewport vport srect {:padding 40}) + (let [srect (gal/adjust-to-viewport vport srect {:padding 40 :min-zoom 0.01}) zoom (/ (:width vport) (:width srect))] (-> local (assoc :zoom zoom) @@ -142,7 +142,7 @@ (fn [{:keys [vport] :as local}] (let [srect (gal/adjust-to-viewport vport srect - {:padding 40}) + {:padding 40 :min-zoom 0.01}) zoom (/ (:width vport) (:width srect))] (-> local diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index c23ce7a07c..b571aea098 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -275,29 +275,26 @@ pub extern "C" fn set_view_end() { state.render_state.options.set_fast_mode(false); state.render_state.cancel_animation_frame(); - let zoom_changed = state.render_state.zoom_changed(); - // Only rebuild tile indices when zoom has changed. - // During pan-only operations, shapes stay in the same tiles - // because tile_size = 1/scale * TILE_SIZE (depends only on zoom). - if zoom_changed { - let _rebuild_start = performance::begin_timed_log!("rebuild_tiles"); - performance::begin_measure!("set_view_end::rebuild_tiles"); - if state.render_state.options.is_profile_rebuild_tiles() { - state.rebuild_tiles(); - } else { - state.rebuild_tiles_shallow(); - } - performance::end_measure!("set_view_end::rebuild_tiles"); - performance::end_timed_log!("rebuild_tiles", _rebuild_start); + // Update tile_viewbox first so that get_tiles_for_shape uses the correct interest area + // This is critical because we limit tiles to the interest area for optimization + let scale = state.render_state.get_scale(); + state + .render_state + .tile_viewbox + .update(state.render_state.viewbox, scale); + + // We rebuild the tile index on both pan and zoom because `get_tiles_for_shape` + // clips each shape to the current `TileViewbox::interest_rect` (viewport-dependent). + let _rebuild_start = performance::begin_timed_log!("rebuild_tiles"); + performance::begin_measure!("set_view_end::rebuild_tiles"); + if state.render_state.options.is_profile_rebuild_tiles() { + state.rebuild_tiles(); } else { - // During pan, we only clear the tile index without - // invalidating cached textures, which is more efficient. - let _clear_start = performance::begin_timed_log!("clear_tile_index"); - performance::begin_measure!("set_view_end::clear_tile_index"); - state.clear_tile_index(); - performance::end_measure!("set_view_end::clear_tile_index"); - performance::end_timed_log!("clear_tile_index", _clear_start); + state.rebuild_tiles_shallow(); } + performance::end_measure!("set_view_end::rebuild_tiles"); + performance::end_timed_log!("rebuild_tiles", _rebuild_start); + state.render_state.sync_cached_viewbox(); performance::end_measure!("set_view_end"); performance::end_timed_log!("set_view_end", _end_start); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 480e510fe5..76eedf0288 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1168,7 +1168,6 @@ impl RenderState { let scale = self.get_scale(); self.tile_viewbox.update(self.viewbox, scale); - self.focus_mode.reset(); performance::begin_measure!("render"); @@ -2111,13 +2110,44 @@ impl RenderState { } /* - * Given a shape returns the TileRect with the range of tiles that the shape is in + * Given a shape returns the TileRect with the range of tiles that the shape is in. + * This is always limited to the interest area to optimize performance and prevent + * processing unnecessary tiles outside the viewport. The interest area already + * includes a margin (VIEWPORT_INTEREST_AREA_THRESHOLD) calculated via + * get_tiles_for_viewbox_with_interest, ensuring smooth pan/zoom interactions. + * + * When the viewport changes (pan/zoom), the interest area is updated and shapes + * are dynamically added to the tile index via the fallback mechanism in + * render_shape_tree_partial_uncached, ensuring all shapes render correctly. */ pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect { let scale = self.get_scale(); let extrect = self.get_cached_extrect(shape, tree, scale); let tile_size = tiles::get_tile_size(scale); - tiles::get_tiles_for_rect(extrect, tile_size) + let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size); + let interest_rect = &self.tile_viewbox.interest_rect; + // Calculate the intersection of shape_tiles with interest_rect + // This returns only the tiles that are both in the shape and in the interest area + let intersection_x1 = shape_tiles.x1().max(interest_rect.x1()); + let intersection_y1 = shape_tiles.y1().max(interest_rect.y1()); + let intersection_x2 = shape_tiles.x2().min(interest_rect.x2()); + let intersection_y2 = shape_tiles.y2().min(interest_rect.y2()); + + // Return the intersection if valid (there is overlap), otherwise return empty rect + if intersection_x1 <= intersection_x2 && intersection_y1 <= intersection_y2 { + // Valid intersection: return the tiles that are in both shape_tiles and interest_rect + TileRect( + intersection_x1, + intersection_y1, + intersection_x2, + intersection_y2, + ) + } else { + // No intersection: shape is completely outside interest area + // The shape will be added dynamically via add_shape_tiles when it enters + // the interest area during pan/zoom operations + TileRect(0, 0, -1, -1) + } } /* @@ -2198,17 +2228,6 @@ impl RenderState { performance::end_measure!("rebuild_tiles_shallow"); } - /// Clears the tile index without invalidating cached tile textures. - /// This is useful when tile positions don't change (e.g., during pan operations) - /// but the tile index needs to be synchronized. The cached tile textures remain - /// valid since they don't depend on the current view position, only on zoom level. - /// This is much more efficient than clearing the entire cache surface. - pub fn clear_tile_index(&mut self) { - performance::begin_measure!("clear_tile_index"); - self.surfaces.clear_tiles(); - performance::end_measure!("clear_tile_index"); - } - pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) { performance::begin_measure!("rebuild_tiles"); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 385408d89f..7762d4b5aa 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -207,10 +207,6 @@ impl State { self.render_state.rebuild_tiles_shallow(&self.shapes); } - pub fn clear_tile_index(&mut self) { - self.render_state.clear_tile_index(); - } - pub fn rebuild_tiles(&mut self) { self.render_state.rebuild_tiles_from(&self.shapes, None); }