Picker Group
This commit is contained in:
+7
-32
@@ -31,40 +31,15 @@ def link_mat_to_object(ob):
|
||||
sl.link = 'OBJECT'
|
||||
sl.material = m
|
||||
|
||||
def find_mirror(name):
|
||||
mirror = None
|
||||
prop= False
|
||||
|
||||
if name:
|
||||
|
||||
if name.startswith('[')and name.endswith(']'):
|
||||
prop = True
|
||||
name= name[:-2][2:]
|
||||
|
||||
match={
|
||||
'R': 'L',
|
||||
'r': 'l',
|
||||
'L': 'R',
|
||||
'l': 'r',
|
||||
}
|
||||
|
||||
separator=['.','_']
|
||||
|
||||
if name.startswith(tuple(match.keys())):
|
||||
if name[1] in separator:
|
||||
mirror = match[name[0]]+name[1:]
|
||||
|
||||
if name.endswith(tuple(match.keys())):
|
||||
if name[-2] in separator:
|
||||
mirror = name[:-1]+match[name[-1]]
|
||||
|
||||
if mirror and prop == True:
|
||||
mirror='["%s"]'%mirror
|
||||
|
||||
return mirror
|
||||
def flip_name(name):
|
||||
if not name:
|
||||
return
|
||||
|
||||
if name.startswith('[') and name.endswith(']'): #It's a custom property
|
||||
flipped_name = bpy.utils.flip_name(name[:-2][2:])
|
||||
return f'["{flipped_name}"]'
|
||||
else:
|
||||
return None
|
||||
return bpy.utils.flip_name(name)
|
||||
|
||||
def hide_layers(args):
|
||||
""" """
|
||||
|
||||
+14
-4
@@ -21,15 +21,25 @@ def bound_box_center(ob):
|
||||
|
||||
return (sum(x) / len(points), sum(y) / len(points),sum(z) / len(points))
|
||||
|
||||
def bounding_rect(points):
|
||||
x_points = sorted(p[0] for p in points)
|
||||
y_points = sorted(p[1] for p in points)
|
||||
|
||||
def intersect_rectangles(bound, border): # returns None if rectangles don't intersect
|
||||
dx = min(border[1][0],bound[1][0]) - max(border[0][0],bound[0][0])
|
||||
dy = min(border[0][1],bound[0][1]) - max(border[2][1],bound[2][1])
|
||||
return [
|
||||
Vector((x_points[0], y_points[-1])),
|
||||
Vector((x_points[-1], y_points[-1])),
|
||||
Vector((x_points[-1], y_points[0])),
|
||||
Vector((x_points[0], y_points[0])),
|
||||
]
|
||||
|
||||
def intersect_rects(rect, border): # returns None if rectangles don't intersect
|
||||
dx = min(border[1][0],rect[1][0]) - max(border[0][0],rect[0][0])
|
||||
dy = min(border[0][1],rect[0][1]) - max(border[2][1],rect[2][1])
|
||||
|
||||
if (dx>=0) and (dy>=0):
|
||||
return dx*dy
|
||||
|
||||
def point_inside_rectangle(point, rect):
|
||||
def point_inside_rect(point, rect):
|
||||
return rect[0][0]< point[0]< rect[1][0] and rect[2][1]< point[1]< rect[0][1]
|
||||
|
||||
def point_over_shape(point,verts,loops,outside_point=(-1,-1)):
|
||||
|
||||
+199
-126
@@ -4,9 +4,11 @@ import gpu
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
import blf
|
||||
from mathutils import bvhtree, Vector
|
||||
from mathutils.geometry import intersect_point_quad_2d, intersect_point_tri_2d, intersect_tri_tri_2d
|
||||
from mathutils.geometry import (intersect_point_quad_2d, intersect_point_tri_2d,
|
||||
intersect_tri_tri_2d)
|
||||
from ..constants import PICKERS
|
||||
from .addon_utils import get_operator_from_id
|
||||
from .geometry_utils import bounding_rect
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -16,7 +18,6 @@ import os
|
||||
import threading
|
||||
|
||||
|
||||
|
||||
class Shape:
|
||||
def __init__(self, picker, points, polygons=None, edges=None, tooltip='', color=None, source_name=''):
|
||||
|
||||
@@ -28,7 +29,6 @@ class Shape:
|
||||
self.hover = False
|
||||
self.press = False
|
||||
|
||||
|
||||
self.shader = gpu.shader.from_builtin('UNIFORM_COLOR')
|
||||
|
||||
#self.hover_shader = gpu.shader.from_builtin('UNIFORM_COLOR')
|
||||
@@ -41,29 +41,10 @@ class Shape:
|
||||
self.polygons = polygons or []
|
||||
self.edges = edges or []
|
||||
|
||||
#print(points, self.polygons)
|
||||
|
||||
self.p_batch = batch_for_shader(self.shader, 'TRIS', {"pos": self.points}, indices=self.polygons)
|
||||
self.e_batch = batch_for_shader(self.shader, 'LINES', {"pos": self.points}, indices=self.edges)
|
||||
|
||||
#if polygons:
|
||||
# self.batch = batch_for_shader(self.shader, 'TRIS', {"pos": points}, indices=polygons)
|
||||
#else:
|
||||
#pts = []
|
||||
#for loop in self.edges:
|
||||
# pts += [self.points[i] for i in loop]
|
||||
|
||||
# self.batch = batch_for_shader(self.shader, 'LINES', {"pos": points}, indices=indices)
|
||||
|
||||
points_x = [v[0] for v in points]
|
||||
points_y = [v[1] for v in points]
|
||||
|
||||
self.bound = [
|
||||
(min(points_x), max(points_y)),
|
||||
(max(points_x), max(points_y)),
|
||||
(max(points_x), min(points_y)),
|
||||
(min(points_x), min(points_y))
|
||||
]
|
||||
self.rect = bounding_rect(points)
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
@@ -97,7 +78,7 @@ class Shape:
|
||||
self.e_batch.draw(self.shader)
|
||||
|
||||
def move_event(self, location):
|
||||
if not intersect_point_quad_2d(location, *self.bound):
|
||||
if not intersect_point_quad_2d(location, *self.rect):
|
||||
self.hover = False
|
||||
return False
|
||||
|
||||
@@ -224,6 +205,7 @@ class BoneShape(Shape):
|
||||
#Overlay the fill slightly with the bone color
|
||||
self.shader.uniform_float("color", (*self.bone_colors['normal'][:3], 0.1))
|
||||
self.p_batch.draw(self.shader)
|
||||
gpu.state.blend_set('NONE')
|
||||
|
||||
|
||||
#self.contour_shader.bind()
|
||||
@@ -238,7 +220,6 @@ class BoneShape(Shape):
|
||||
self.e_batch.draw(self.shader)
|
||||
|
||||
gpu.state.line_width_set(1.0)
|
||||
gpu.state.blend_set('NONE')
|
||||
|
||||
def assign_bone_event(self):
|
||||
|
||||
@@ -275,11 +256,6 @@ class BoneShape(Shape):
|
||||
|
||||
|
||||
def border_select(self, border, mode='SET'):
|
||||
'''
|
||||
if ( not any(intersect_point_quad_2d(b, *self.bound) for b in border) and
|
||||
not any(intersect_point_quad_2d(b, *border) for b in self.bound) ):
|
||||
return
|
||||
'''
|
||||
if not self.bone:
|
||||
return
|
||||
|
||||
@@ -287,16 +263,16 @@ class BoneShape(Shape):
|
||||
self.bone.bone.select = False
|
||||
return
|
||||
|
||||
bound_tri1 = self.bound[0], self.bound[1], self.bound[2]
|
||||
bound_tri2 = self.bound[2], self.bound[3], self.bound[0]
|
||||
rect_tri1 = self.rect[0], self.rect[1], self.rect[2]
|
||||
rect_tri2 = self.rect[2], self.rect[3], self.rect[0]
|
||||
|
||||
border_tri1 = border[0], border[1], border[2]
|
||||
border_tri2 = border[2], border[3], border[0]
|
||||
|
||||
if (not intersect_tri_tri_2d(*border_tri1, *bound_tri1) and
|
||||
not intersect_tri_tri_2d(*border_tri1, *bound_tri2) and
|
||||
not intersect_tri_tri_2d(*border_tri2, *bound_tri1) and
|
||||
not intersect_tri_tri_2d(*border_tri2, *bound_tri2)):
|
||||
if (not intersect_tri_tri_2d(*border_tri1, *rect_tri1) and
|
||||
not intersect_tri_tri_2d(*border_tri1, *rect_tri2) and
|
||||
not intersect_tri_tri_2d(*border_tri2, *rect_tri1) and
|
||||
not intersect_tri_tri_2d(*border_tri2, *rect_tri2)):
|
||||
return
|
||||
|
||||
select = True
|
||||
@@ -388,116 +364,89 @@ class Picker:
|
||||
self.region = bpy.context.region
|
||||
self.rig = rig
|
||||
|
||||
self.translation = Vector((0, 0))
|
||||
self.shapes = []
|
||||
self.box_select = None
|
||||
self.hover_shape = None
|
||||
self.shader = gpu.shader.from_builtin('UNIFORM_COLOR')
|
||||
|
||||
self.tooltip_shape = None
|
||||
self.tooltip_mouse = None
|
||||
self.tooltip = ''
|
||||
|
||||
self.timer = None
|
||||
self.mouse = None
|
||||
|
||||
for s in shapes:
|
||||
if not s['points']:
|
||||
for shape_data in shapes:
|
||||
if not shape_data['points']:
|
||||
continue
|
||||
|
||||
if s['type'] in ('CANVAS', 'DISPLAY'):
|
||||
if shape_data['type'] in ('CANVAS', 'DISPLAY'):
|
||||
shape = Shape(
|
||||
self,
|
||||
points=s['points'],
|
||||
polygons=s['polygons'],
|
||||
edges=s['edges'],
|
||||
color=s['color']
|
||||
points=shape_data['points'],
|
||||
polygons=shape_data['polygons'],
|
||||
edges=shape_data['edges'],
|
||||
color=shape_data['color']
|
||||
)
|
||||
|
||||
elif s['type'] == 'BONE':
|
||||
bone = rig.pose.bones.get(s['bone'])
|
||||
elif shape_data['type'] == 'BONE':
|
||||
bone = rig.pose.bones.get(shape_data['bone'])
|
||||
#if not bone:
|
||||
# print(f'Bone {s["bone"]} not exist')
|
||||
# print(f'Bone {shape_data["bone"]} not exist')
|
||||
# continue
|
||||
|
||||
shape = BoneShape(
|
||||
self,
|
||||
source_name=s['source_name'],
|
||||
points=s['points'],
|
||||
polygons=s['polygons'],
|
||||
edges=s['edges'],
|
||||
source_name=shape_data['source_name'],
|
||||
points=shape_data['points'],
|
||||
polygons=shape_data['polygons'],
|
||||
edges=shape_data['edges'],
|
||||
bone=bone,
|
||||
color=s['color']
|
||||
color=shape_data['color']
|
||||
)
|
||||
|
||||
elif s['type'] == 'OPERATOR':
|
||||
elif shape_data['type'] == 'OPERATOR':
|
||||
shape = OperatorShape(
|
||||
self,
|
||||
source_name=s['source_name'],
|
||||
points=s['points'],
|
||||
polygons=s['polygons'],
|
||||
operator=s['operator'],
|
||||
color=s['color'],
|
||||
tooltip=s['tooltip'],
|
||||
source_name=shape_data['source_name'],
|
||||
points=shape_data['points'],
|
||||
polygons=shape_data['polygons'],
|
||||
operator=shape_data['operator'],
|
||||
color=shape_data['color'],
|
||||
tooltip=shape_data['tooltip'],
|
||||
)
|
||||
|
||||
self.shapes.append(shape)
|
||||
|
||||
self.rect = bounding_rect([p for s in self.shapes for p in s.points])
|
||||
|
||||
def assign_bone_event(self):
|
||||
for s in self.shapes:
|
||||
if s.type=='bone' and s.hover:
|
||||
s.assign_bone_event()
|
||||
for shape in self.shapes:
|
||||
if shape.type=='bone' and shape.hover:
|
||||
shape.assign_bone_event()
|
||||
|
||||
bpy.ops.rigpicker.save_picker()
|
||||
|
||||
def press_event(self, mode='SET'):
|
||||
for s in self.shapes:
|
||||
if s.hover:
|
||||
s.press_event(mode)
|
||||
for shape in self.shapes:
|
||||
#print(s)
|
||||
if shape.hover:
|
||||
shape.press_event(mode)
|
||||
else:
|
||||
s.press = False
|
||||
shape.press = False
|
||||
|
||||
def release_event(self, mode='SET'):
|
||||
if mode == 'SET':
|
||||
for b in self.rig.pose.bones:
|
||||
b.bone.select = False
|
||||
|
||||
|
||||
#bpy.ops.pose.select_all(action='DESELECT')
|
||||
|
||||
#print('PICKER release event', mode)
|
||||
#print(f'type={event.type}, value={event.value}, ctrl={event.ctrl}, shift={event.shift}, alt={event.alt}')
|
||||
|
||||
for s in self.shapes:
|
||||
if s.hover:
|
||||
s.release_event(mode)
|
||||
for shape in self.shapes:
|
||||
if shape.hover:
|
||||
shape.release_event(mode)
|
||||
|
||||
s.press = False
|
||||
shape.press = False
|
||||
|
||||
#bpy.context.area.tag_redraw()
|
||||
|
||||
|
||||
def tooltip_event(self):
|
||||
#print('Tooltip Event', self)
|
||||
|
||||
#print(self.hover_shape, self.hover_shape.type)
|
||||
|
||||
if self.hover_shape and self.hover_shape.type != 'display':
|
||||
if self.hover_shape.type == 'bone':
|
||||
self.tooltip = self.hover_shape.bone.name
|
||||
else:
|
||||
self.tooltip = self.hover_shape.tooltip
|
||||
self.tooltip_shape = self.hover_shape
|
||||
else:
|
||||
self.tooltip = ''
|
||||
self.tooltip_shape = None
|
||||
|
||||
self.tooltip_mouse = self.mouse
|
||||
|
||||
self.timer.cancel()
|
||||
|
||||
#print(self.tooltip)
|
||||
|
||||
self.region.tag_redraw()
|
||||
|
||||
'''
|
||||
picker.tooltip_event(event='SHOW')
|
||||
region.tag_redraw()
|
||||
@@ -524,27 +473,27 @@ class Picker:
|
||||
'''
|
||||
|
||||
def border_select(self, border, mode):
|
||||
border = [bpy.context.region.view2d.region_to_view(*b) for b in border]
|
||||
border = [Vector(b) - Vector(self.translation) for b in border]
|
||||
|
||||
if mode == 'SET':
|
||||
for b in self.rig.pose.bones:
|
||||
b.bone.select = False
|
||||
|
||||
for s in (s for s in self.shapes if s.type=='bone'):
|
||||
s.border_select(border, mode)
|
||||
|
||||
for shape in self.shapes:
|
||||
if shape.type != 'bone':
|
||||
continue
|
||||
|
||||
shape.border_select(border, mode)
|
||||
|
||||
def move_event(self, location):
|
||||
self.mouse = location
|
||||
location = self.region.view2d.region_to_view(*location)
|
||||
self.mouse = Vector(location) - Vector(self.translation)
|
||||
|
||||
self.hover_shape = None
|
||||
for shape in reversed(self.shapes):
|
||||
if self.hover_shape:
|
||||
shape.hover = False
|
||||
elif shape.move_event(location):
|
||||
elif shape.move_event(self.mouse):
|
||||
self.hover_shape = shape
|
||||
|
||||
#if point_inside_rectangle(self.end, bound):
|
||||
#if point_inside_rect(self.end, rect):
|
||||
# over = point_over_shape(self.end,points, edges)
|
||||
|
||||
#if bpy.app.timers.is_registered(self.tooltip_event):
|
||||
@@ -552,6 +501,85 @@ class Picker:
|
||||
# bpy.app.timers.unregister(self.tooltip_event)
|
||||
#except:
|
||||
# pass
|
||||
|
||||
#bpy.app.timers.register(self.tooltip_event, first_interval=1)
|
||||
|
||||
def draw(self):
|
||||
with gpu.matrix.push_pop():
|
||||
gpu.matrix.translate(self.translation)
|
||||
for shape in self.shapes:
|
||||
shape.draw()
|
||||
|
||||
def under_mouse(self, location):
|
||||
location = Vector(location) - Vector(self.translation)
|
||||
return intersect_point_quad_2d(location, *self.rect)
|
||||
|
||||
@property
|
||||
def center(self):
|
||||
relative_center = (self.rect[1] + self.rect[3]) * 0.5
|
||||
return relative_center + self.translation
|
||||
|
||||
|
||||
class PickerGroup:
|
||||
def __init__(self, rig, pickers):
|
||||
|
||||
self.view_location = Vector((0, 0))
|
||||
self.region = bpy.context.region
|
||||
self.rig = rig
|
||||
self.pickers = []
|
||||
|
||||
self.mouse = Vector((0, 0))
|
||||
|
||||
self.hover_shape = None
|
||||
|
||||
self.tooltip_shape = None
|
||||
self.tooltip_mouse = Vector((0, 0))
|
||||
self.tooltip = ''
|
||||
|
||||
self.timer = None
|
||||
|
||||
for picker in pickers:
|
||||
self.add_picker(picker)
|
||||
|
||||
def add_picker(self, picker):
|
||||
self.pickers.append(Picker(self.rig, picker))
|
||||
|
||||
def draw(self):
|
||||
y = 0
|
||||
for picker in self.pickers:
|
||||
height = picker.rect[1][1] - picker.rect[-1][1]
|
||||
|
||||
picker.translation = Vector((0, -y-height*0.5))
|
||||
picker.draw()
|
||||
|
||||
y += height + 50
|
||||
|
||||
#break #TODO for now only draw first picker
|
||||
|
||||
def move_event(self, mouse):
|
||||
self.mouse = mouse
|
||||
location = self.region.view2d.region_to_view(*mouse)
|
||||
|
||||
view_location = self.region.view2d.region_to_view(0, 0)
|
||||
if view_location != self.view_location:
|
||||
self.view_location = view_location
|
||||
self.tooltip = ''
|
||||
if self.timer:
|
||||
self.timer.cancel()
|
||||
return
|
||||
|
||||
hover_shape = None
|
||||
for picker in self.pickers:
|
||||
if not picker.under_mouse(location):
|
||||
continue
|
||||
|
||||
picker.move_event(location)
|
||||
|
||||
if picker.hover_shape:
|
||||
hover_shape = picker.hover_shape
|
||||
|
||||
self.hover_shape = hover_shape
|
||||
|
||||
if self.tooltip_shape is not self.hover_shape:
|
||||
self.tooltip = ''
|
||||
|
||||
@@ -561,27 +589,70 @@ class Picker:
|
||||
self.timer = threading.Timer(0.5, self.tooltip_event)
|
||||
self.timer.start()
|
||||
|
||||
def press_event(self, mode='SET'):
|
||||
self.clear_tooltip()
|
||||
|
||||
#bpy.app.timers.register(self.tooltip_event, first_interval=1)
|
||||
for picker in self.pickers:
|
||||
picker.press_event(mode)
|
||||
|
||||
def draw(self):
|
||||
for s in self.shapes:
|
||||
s.draw()
|
||||
'''
|
||||
if self.box_select:
|
||||
def release_event(self, mode='SET'):
|
||||
|
||||
self.box_shader.uniform_float("color", self.box_select_color)
|
||||
batch = batch_for_shader(self.shader, 'LINE_LOOP', {"pos": []})
|
||||
if mode == 'SET':
|
||||
for bone in self.rig.data.bones:
|
||||
bone.select = False
|
||||
|
||||
for picker in self.pickers:
|
||||
picker.release_event(mode)
|
||||
|
||||
def border_select(self, border, mode):
|
||||
border = [self.region.view2d.region_to_view(*b) for b in border]
|
||||
|
||||
if mode == 'SET':
|
||||
for bone in self.rig.data.bones:
|
||||
bone.select = False
|
||||
|
||||
for picker in self.pickers:
|
||||
picker.border_select(border, mode)
|
||||
|
||||
def clear_tooltip(self):
|
||||
self.tooltip = ''
|
||||
self.tooltip_shape = None
|
||||
self.timer.cancel()
|
||||
self.region.tag_redraw()
|
||||
|
||||
def tooltip_event(self):
|
||||
#print('Tooltip Event', self)
|
||||
|
||||
#print(self.hover_shape, self.hover_shape.type)
|
||||
|
||||
if self.hover_shape and self.hover_shape.type != 'display':
|
||||
if self.hover_shape.type == 'bone':
|
||||
self.tooltip = self.hover_shape.bone.name
|
||||
else:
|
||||
self.tooltip = self.hover_shape.tooltip
|
||||
self.tooltip_shape = self.hover_shape
|
||||
else:
|
||||
return self.clear_tooltip()
|
||||
|
||||
for b in self.contour_batches:
|
||||
b.draw(self.contour_shader)
|
||||
|
||||
self.batch.draw(self.shader)
|
||||
self.tooltip_mouse = self.mouse
|
||||
|
||||
self.timer.cancel()
|
||||
|
||||
#print(self.tooltip)
|
||||
|
||||
self.region.tag_redraw()
|
||||
|
||||
@property
|
||||
def rect(self):
|
||||
return bounding_rect([co - Vector(p.translation) for p in self.pickers for co in p.rect])
|
||||
|
||||
@property
|
||||
def center(self):
|
||||
center = sum(self.rect, Vector((0, 0))) / len(self.rect)
|
||||
center[1] = -center[1]
|
||||
return center
|
||||
|
||||
|
||||
gpu.state.blend_set('NONE')
|
||||
'''
|
||||
|
||||
def get_picker_path(rig, start=None):
|
||||
picker_path = rig.data.get('rig_picker', {}).get('source')
|
||||
@@ -592,13 +663,15 @@ def get_picker_path(rig, start=None):
|
||||
|
||||
return Path(os.path.abspath(picker_path))
|
||||
|
||||
|
||||
def pack_picker(rig, start=None):
|
||||
picker_path = get_picker_path(rig, start=start)
|
||||
if picker_path and picker_path.exists():
|
||||
if 'rig_picker' not in rig.data.keys():
|
||||
rig.data['rig_picker'] = {}
|
||||
|
||||
rig.data['rig_picker']['picker'] = json.loads(picker_path.read_text())
|
||||
rig.data['rig_picker']['picker'] = json.loads(picker_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def unpack_picker(rig):
|
||||
if 'rig_picker' not in rig.data.keys():
|
||||
|
||||
+6
-6
@@ -20,7 +20,7 @@ def border_over_shape(border,verts,loops):
|
||||
return True
|
||||
|
||||
for point in verts:
|
||||
if point_inside_rectangle(point,border):
|
||||
if point_inside_rect(point,border):
|
||||
return True
|
||||
|
||||
for point in border:
|
||||
@@ -29,7 +29,7 @@ def border_over_shape(border,verts,loops):
|
||||
|
||||
|
||||
def border_loop(vert, loop):
|
||||
border_edge =[e for e in vert.link_edges if e.is_boundary]
|
||||
border_edge =[e for e in vert.link_edges if e.is_rectary]
|
||||
|
||||
if border_edge:
|
||||
for edge in border_edge:
|
||||
@@ -61,7 +61,7 @@ def contour_loops(bm, vert_index=0, loops=None, vert_indices=None):
|
||||
|
||||
return loops
|
||||
|
||||
def get_picker_datas(objects, canvas, rig):
|
||||
def get_picker_data(objects, canvas, rig):
|
||||
picker_datas = []
|
||||
gamma = 1 / 2.2
|
||||
|
||||
@@ -72,11 +72,11 @@ def get_picker_datas(objects, canvas, rig):
|
||||
|
||||
canvas_coords = [canvas.matrix_world@Vector((p.co)) for p in canvas_points]
|
||||
|
||||
canvas_x = [p[0] for p in canvas_coords]
|
||||
canvas_y = [p[1] for p in canvas_coords]
|
||||
height = max(canvas_coords).y - min(canvas_coords).y
|
||||
width = max(canvas_coords).x - min(canvas_coords).x
|
||||
|
||||
canvas_center = sum(canvas_coords, Vector()) / len(canvas_coords)
|
||||
canvas_scale_fac = 4096 / (max(canvas_y) - min(canvas_y))# Reference height for the canvas
|
||||
canvas_scale_fac = 4096 / max(height, width)# Reference height for the canvas
|
||||
|
||||
objects.append(canvas)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user