mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Merge pull request #3070 from nrq6993-a11y/stream/vc-feat3-g2-texturizer-load-texture
G2: Texturizer Load Texture in effect dialog
This commit is contained in:
@@ -666,7 +666,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"texture.texturizer",
|
||||
"Texturizer…",
|
||||
TEXTURE,
|
||||
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
|
||||
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray",
|
||||
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
|
||||
),
|
||||
r(
|
||||
|
||||
@@ -40,7 +40,7 @@ pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
|
||||
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 48] = [
|
||||
@@ -495,7 +495,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.texturizer" => PixelFx::Texturizer {
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
@@ -697,7 +697,7 @@ impl PixelFx {
|
||||
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
|
||||
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
|
||||
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
|
||||
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
|
||||
PixelFx::Texturizer { ref texture, scaling, relief, light, invert } => {
|
||||
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
|
||||
|
||||
@@ -1426,6 +1426,32 @@ fn texturizer_lights_each_surface_from_its_side() {
|
||||
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturizer_draws_a_loaded_texture_as_relief() {
|
||||
let w = 64;
|
||||
let mid = grey(150);
|
||||
let rings = stored(12, 10, |x, y| {
|
||||
let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs();
|
||||
let g = (v * 255.0).round() as u8;
|
||||
[g, g, g, 255]
|
||||
});
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = flat(w, mid);
|
||||
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
|
||||
d
|
||||
};
|
||||
let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20}));
|
||||
assert_ne!(relief, flat(w, mid));
|
||||
for (_, builtin) in TEXTURES {
|
||||
assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}");
|
||||
}
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid));
|
||||
assert_eq!(
|
||||
fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})),
|
||||
fx("texture.texturizer", json!({}))
|
||||
);
|
||||
}
|
||||
|
||||
/// The Texture filters' patterns lie in document space around the object's centre: the same at
|
||||
/// twice the resolution, and moving with the object.
|
||||
#[test]
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
//! whose slopes, taken in document units, are lit from one side.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, PI};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::loaded::{LOADED, LoadedTexture, texture};
|
||||
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
@@ -65,13 +67,19 @@ impl Grain {
|
||||
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
|
||||
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
|
||||
|
||||
/// A Texturizer surface ([`TEXTURES`]), made in code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// Texturizer's Texture menu once a texture is loaded: [`TEXTURES`], then the loaded one.
|
||||
pub const TEXTURES_LOADED: [(&str, &str); 5] =
|
||||
[("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone"), ("Loaded Texture", LOADED)];
|
||||
|
||||
/// A Texturizer surface ([`TEXTURES`]), made in code or loaded from an image.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Texture {
|
||||
Brick,
|
||||
Burlap,
|
||||
Canvas,
|
||||
Sandstone,
|
||||
/// An image the user loaded ([`LoadedTexture`]).
|
||||
Loaded(Arc<LoadedTexture>),
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
@@ -82,13 +90,23 @@ impl Texture {
|
||||
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
|
||||
}
|
||||
|
||||
/// The texture of `value` with `stored` the effect's `textureImage`: the loaded texture for
|
||||
/// [`LOADED`] (any case) when `stored` holds one, else as [`Texture::parse`].
|
||||
pub fn parse_with(value: &str, stored: Option<&str>) -> Self {
|
||||
match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) {
|
||||
Some(t) => Texture::Loaded(t),
|
||||
None => Self::parse(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
pub(super) fn height(self, p: Point) -> f64 {
|
||||
pub(super) fn height(&self, p: Point) -> f64 {
|
||||
match self {
|
||||
Texture::Brick => brick(p),
|
||||
Texture::Burlap => weave(p, 7.0, true),
|
||||
Texture::Canvas => weave(p, 4.0, false),
|
||||
Texture::Sandstone => sandstone(p),
|
||||
Texture::Loaded(t) => t.height(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,7 +574,7 @@ pub(super) fn texturizer(
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
texture: Texture,
|
||||
texture: &Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
|
||||
@@ -145,7 +145,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
}
|
||||
if id == GLASS {
|
||||
if texture_load_effect(id) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
@@ -321,7 +321,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
super::params::check_effect_channel(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
if edited.iter().any(|e| texture_load_effect(e)) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
@@ -374,6 +374,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Effect › Distort › Glass.
|
||||
const GLASS: &str = "distort.glass";
|
||||
/// Effect › Texture › Texturizer.
|
||||
const TEXTURIZER: &str = "texture.texturizer";
|
||||
|
||||
fn texture_load_effect(id: &str) -> bool {
|
||||
id == GLASS || id == TEXTURIZER
|
||||
}
|
||||
|
||||
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
|
||||
/// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless
|
||||
|
||||
@@ -336,6 +336,29 @@ fn glass_refuses_a_texture_that_is_not_an_image() {
|
||||
assert_eq!(glass_params(&s, g), before);
|
||||
}
|
||||
|
||||
/// Effect › Texture › Texturizer › Load Texture: an image is stored with the effect and drawn in relief.
|
||||
#[test]
|
||||
fn texturizer_loads_a_texture_and_draws_it() {
|
||||
let png = rings_png();
|
||||
let data = vectorcraft_format::base64_encode(&png);
|
||||
let (mut s, g) = striped();
|
||||
let file = json!({"name": "rings.png", "dataBase64": data});
|
||||
s.execute("effect.apply", &json!({"effect": "texture.texturizer", "ids": [g], "params": {"relief": 20, "textureImage": file}})).unwrap();
|
||||
let p = glass_params(&s, g);
|
||||
assert_eq!(p["texture"], json!("loaded"));
|
||||
let loaded = render(&s);
|
||||
assert!(glass_params(&s, g)["textureImage"].as_str().is_some());
|
||||
for (_, builtin) in vectorcraft_render::effects::pixel::TEXTURES {
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": builtin}})).unwrap();
|
||||
assert!(mean_diff(&loaded, &render(&s)) > 0.001, "{builtin}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": "loaded"}})).unwrap();
|
||||
assert_eq!(render(&s).pixels, loaded.pixels);
|
||||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g));
|
||||
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_reaches_past_the_corners() {
|
||||
let (mut s, g) = striped();
|
||||
|
||||
@@ -156,7 +156,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None if id == GLASS => glass_fields(app, ui, d),
|
||||
None if id == GLASS => glass_fields(app, ui, d, GLASS),
|
||||
None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER),
|
||||
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
|
||||
None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()),
|
||||
None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()),
|
||||
@@ -219,20 +220,26 @@ fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
|
||||
/// The Glass effect (Effect › Distort › Glass…).
|
||||
const GLASS: &str = "distort.glass";
|
||||
/// The Texturizer effect (Effect › Texture › Texturizer…).
|
||||
const TEXTURIZER: &str = "texture.texturizer";
|
||||
|
||||
/// Glass's dialog: its parameters, the Texture menu ending with Loaded Texture once a texture is
|
||||
/// Glass and Texturizer: parameters, the Texture menu ending with Loaded Texture once a texture is
|
||||
/// loaded, and Load Texture… with the loaded texture's size. Returns whether the values differ from
|
||||
/// those last previewed.
|
||||
fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) -> bool {
|
||||
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
|
||||
// The stored texture is shown as its size, not as a text field.
|
||||
let stored = d.fields.remove(TEXTURE_IMAGE);
|
||||
let size = stored.as_ref().and_then(Value::as_str).and_then(texture).map(|t| t.size());
|
||||
let menu = glass_textures(size.is_some());
|
||||
let doc = vectorcraft_effects::effect_info(GLASS).map(|e| e.params).unwrap_or_default();
|
||||
let menu = if id == TEXTURIZER {
|
||||
texturizer_textures(size.is_some())
|
||||
} else {
|
||||
glass_textures(size.is_some())
|
||||
};
|
||||
let doc = vectorcraft_effects::effect_info(id).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(GLASS, k) };
|
||||
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(GLASS, k, false), &pick, &|k| doc_rank(doc, k), unit);
|
||||
let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(id, k) };
|
||||
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(id, k, false), &pick, &|k| doc_rank(doc, k), unit);
|
||||
if let Some(v) = stored {
|
||||
d.fields.insert(TEXTURE_IMAGE.into(), v);
|
||||
}
|
||||
@@ -259,6 +266,12 @@ fn glass_textures(loaded: bool) -> form::Choices {
|
||||
if loaded { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES }
|
||||
}
|
||||
|
||||
/// Texturizer's Texture menu, ending with Loaded Texture once a texture is `loaded`.
|
||||
fn texturizer_textures(loaded: bool) -> form::Choices {
|
||||
use vectorcraft_effects::pixel::{TEXTURES, TEXTURES_LOADED};
|
||||
if loaded { &TEXTURES_LOADED } else { &TEXTURES }
|
||||
}
|
||||
|
||||
/// Glass's Load Texture…: an image file the user picks, read into `textureImage` as Glass stores
|
||||
/// it and picked in the Texture menu.
|
||||
fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
@@ -344,9 +357,10 @@ pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>
|
||||
if let Some(own) = own_choices(effect, key, cmyk) {
|
||||
return Some(own.into_iter().map(|(label, value)| (tl!(label).to_string(), value.to_string())).collect());
|
||||
}
|
||||
let options = if effect == GLASS && key == "texture" {
|
||||
let options = if (effect == GLASS || effect == TEXTURIZER) && key == "texture" {
|
||||
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
|
||||
glass_textures(params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some())
|
||||
let loaded = params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some();
|
||||
if effect == GLASS { glass_textures(loaded) } else { texturizer_textures(loaded) }
|
||||
} else {
|
||||
choices(effect, key)?
|
||||
};
|
||||
@@ -772,4 +786,34 @@ mod tests {
|
||||
assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Texturizer's Load Texture… matches Glass: pick, Loaded Texture in the menu, OK applies.
|
||||
#[test]
|
||||
fn texturizer_load_texture_reads_the_picked_image_into_the_effect() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-texturizer-dialog-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("texture.png");
|
||||
image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap();
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let picked = path.to_string_lossy().into_owned();
|
||||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
|
||||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": TEXTURIZER})).unwrap();
|
||||
let paint = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()));
|
||||
let text = paint(&mut app);
|
||||
assert!(text.lines().any(|l| l == "Load Texture…") && text.lines().any(|l| l == "Canvas"), "{text}");
|
||||
let mut d = app.ui.dialog.take().unwrap();
|
||||
load_texture(&mut app, &mut d).unwrap();
|
||||
let stored = d.str(vectorcraft_effects::pixel::TEXTURE_IMAGE);
|
||||
assert_eq!((d.str("texture"), vectorcraft_effects::pixel::texture(&stored).map(|t| t.size())), ("loaded".to_string(), Some((6, 4))));
|
||||
app.ui.dialog = Some(d);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let st = app.session.doc().unwrap();
|
||||
let params = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params;
|
||||
assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ its resolution, PDF export warns once per object with the requested and the lowe
|
||||
| Sharpen | Unsharp Mask | 1/1 | — |
|
||||
| Sketch | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | 12/14 (approximate Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Water Paper; Chalk & Charcoal, Graphic Pen, Note Paper and Water Paper defaults unverified) | Conté Crayon, Torn Edges |
|
||||
| Stylize | Glowing Edges | 1/1 | — |
|
||||
| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | Texturizer's Load Texture |
|
||||
| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | — |
|
||||
| Video | De-Interlace, NTSC Colors | 2/2 | — |
|
||||
| Effect Gallery | the combined browser and stack dialog | 0/1 | all |
|
||||
|
||||
|
||||
@@ -228,6 +228,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G2 Photoshop-style raster effects and the Effect Gallery
|
||||
|
||||
- **Done:** Effect › Texture › Texturizer › Load Texture… (stored grayscale `textureImage`, Loaded Texture in the Texture menu), matching Glass.
|
||||
- **Missing:** 24 of 57 filters (the remaining 11 Artistic filters and 13 of Sketch's 14), the Effect Gallery dialog. Measured in
|
||||
- **Missing:** 10 of 57 filters (7 Artistic, 2 Sketch and 1 more), the Effect Gallery dialog, Load
|
||||
Texture for Texturizer. Measured in
|
||||
[effects-parity.md](effects-parity.md).
|
||||
|
||||
Reference in New Issue
Block a user