diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 7a81f49d0c..0857abde3e 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -28,6 +28,12 @@ included in this changelog. - `GET /{project_id}/labels/detail`: Returns a list of of label objects, each with a name field and an ID field. +- `POST /{project_id}/volumes/import`: + Import volumes as STL files. + +- `GET /{project_id}/volumes/{volume_id}/export.{extension}`: + Export a particular volume. Currentl only exports AS STL are supported. + ### Modifications - `POST /{project_id}/skeletons/node-label`: @@ -54,6 +60,13 @@ included in this changelog. marked as soma in the SWC export. The first matching condition in this order wins. +- `GET /{project_id}/volumes/`: + The return format changed. Instead of a list of volume objects an object with + a 'columns' field and a 'data' field are returned. The data fields contains a + list of lists, with each inner list being a volume. The entries are described + by the 'columns' field. Along with the already returned fields, annotations + are now retuned as well. + ### Deprecations None. @@ -75,6 +88,9 @@ None. Accepts the same parameters as the GET variant, but allows for larger skeleton_ids list. +- `GET /{project_id}/volumes/entities`: + Return a mapping of volume IDs to their respective class instance ID. + ### Modifications - `GET /{project_id}/skeletons/in-bounding-box`: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c96b3bded..c16b092404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,11 @@ Miscellaneous: - Admin: a user import view is now available to import users from other CATMAID instances. It requires superuser permissions on the remote instance. +- DB integrity check management command: volumes are now checked to make sure + all faces are triangles. The --tracing [true|false] and --volumes [true|false] + command line parameters now allow to explicitly test only some parts of the + database. By default all is tested. + ### Bug fixes diff --git a/django/applications/catmaid/control/project.py b/django/applications/catmaid/control/project.py index c207bdd598..74a77f9948 100644 --- a/django/applications/catmaid/control/project.py +++ b/django/applications/catmaid/control/project.py @@ -23,7 +23,8 @@ 'annotation': "An arbitrary annotation", 'stack_property': 'A property which a stack has', 'landmark': "A particular type of location", - "landmarkgroup": "A type of collection that groups landmarks" + "landmarkgroup": "A type of collection that groups landmarks", + 'volume': 'A region of space' } # All relations needed by the tracing system alongside their @@ -34,6 +35,7 @@ 'annotated_with': "Something is annotated by something else.", 'has_property': 'A thing which has an arbitrary property', 'close_to': 'Something is spatially in the neighborhood of something else', + 'model_of': "Marks something as a model of something else." } # All client datastores needed by the tracing system along their descriptions. diff --git a/django/applications/catmaid/control/tracing.py b/django/applications/catmaid/control/tracing.py index 943367b7d1..2762770099 100644 --- a/django/applications/catmaid/control/tracing.py +++ b/django/applications/catmaid/control/tracing.py @@ -28,7 +28,6 @@ needed_relations = { 'labeled_as': "Something is labeled by sth. else.", 'element_of': "A generic element-of relationship", - 'model_of': "Marks something as a model of something else.", 'presynaptic_to': "Something is presynaptic to something else.", 'postsynaptic_to': "Something is postsynaptic to something else.", 'abutting': "Two things abut against each other", diff --git a/django/applications/catmaid/control/volume.py b/django/applications/catmaid/control/volume.py index 6f9ed45fa3..6886e5d9ce 100644 --- a/django/applications/catmaid/control/volume.py +++ b/django/applications/catmaid/control/volume.py @@ -1,17 +1,23 @@ # -*- coding: utf-8 -*- import json +import os import re +from xml.etree import ElementTree as ET + +from django.conf import settings from catmaid.control.authentication import requires_user_role, user_can_edit +from catmaid.control.common import get_request_list from catmaid.models import UserRole, Project, Volume from catmaid.serializers import VolumeSerializer from django.db import connection -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404 -from rest_framework.decorators import api_view +from rest_framework import renderers +from rest_framework.decorators import api_view, renderer_classes from rest_framework.response import Response from six.moves import map @@ -103,11 +109,33 @@ def save(self): raise ValueError("Can't create new volume without mesh") cursor.execute(""" - INSERT INTO catmaid_volume (user_id, project_id, editor_id, name, - comment, creation_time, edition_time, geometry) - VALUES (%(uid)s, %(pid)s, %(uid)s, %(t)s, %(c)s, now(), now(), """ + - surface + """) - RETURNING id;""", params) + WITH v AS ( + INSERT INTO catmaid_volume (user_id, project_id, editor_id, name, + comment, creation_time, edition_time, geometry) + VALUES (%(uid)s, %(pid)s, %(uid)s, %(t)s, %(c)s, now(), now(), """ + + surface + """) + RETURNING user_id, project_id, id + ), ci AS ( + INSERT INTO class_instance (user_id, project_id, name, class_id) + SELECT %(uid)s, project_id, %(t)s, id + FROM class + WHERE project_id = %(pid)s AND class_name = 'volume' + RETURNING id + ), r AS ( + SELECT id FROM relation + WHERE project_id = %(pid)s AND relation_name = 'model_of' + ) + INSERT INTO volume_class_instance + (user_id, project_id, relation_id, volume_id, class_instance_id) + SELECT + v.user_id, + v.project_id, + r.id, + v.id, + ci.id + FROM v, ci, r + RETURNING volume_id + """, params) return cursor.fetchone()[0] @@ -166,19 +194,32 @@ def __init__(self, project_id, user_id, options): self.max_z = get_req_coordinate(options, "max_z") def get_geometry(self): - return """ST_GeomFromEWKT('POLYHEDRALSURFACE ( - ((%(lx)s %(ly)s %(lz)s, %(lx)s %(hy)s %(lz)s, %(hx)s %(hy)s %(lz)s, - %(hx)s %(ly)s %(lz)s, %(lx)s %(ly)s %(lz)s)), - ((%(lx)s %(ly)s %(lz)s, %(lx)s %(hy)s %(lz)s, %(lx)s %(hy)s %(hz)s, - %(lx)s %(ly)s %(hz)s, %(lx)s %(ly)s %(lz)s)), - ((%(lx)s %(ly)s %(lz)s, %(hx)s %(ly)s %(lz)s, %(hx)s %(ly)s %(hz)s, - %(lx)s %(ly)s %(hz)s, %(lx)s %(ly)s %(lz)s)), - ((%(hx)s %(hy)s %(hz)s, %(hx)s %(ly)s %(hz)s, %(lx)s %(ly)s %(hz)s, - %(lx)s %(hy)s %(hz)s, %(hx)s %(hy)s %(hz)s)), - ((%(hx)s %(hy)s %(hz)s, %(hx)s %(ly)s %(hz)s, %(hx)s %(ly)s %(lz)s, - %(hx)s %(hy)s %(lz)s, %(hx)s %(hy)s %(hz)s)), - ((%(hx)s %(hy)s %(hz)s, %(hx)s %(hy)s %(lz)s, %(lx)s %(hy)s %(lz)s, - %(lx)s %(hy)s %(hz)s, %(hx)s %(hy)s %(hz)s)))')""" + return """ST_GeomFromEWKT('TIN ( + (({0}, {2}, {1}, {0})), + (({1}, {2}, {3}, {1})), + + (({0}, {1}, {5}, {0})), + (({0}, {5}, {4}, {0})), + + (({2}, {6}, {7}, {2})), + (({2}, {7}, {3}, {2})), + + (({4}, {7}, {6}, {4})), + (({4}, {5}, {7}, {4})), + + (({0}, {6}, {2}, {0})), + (({0}, {4}, {6}, {0})), + + (({1}, {3}, {5}, {1})), + (({3}, {7}, {5}, {3})))') + """.format(*[ + '%({a})s %({b})s %({c})s'.format(**{ + 'a': 'hx' if i & 0b001 else 'lx', + 'b': 'hy' if i & 0b010 else 'ly', + 'c': 'hz' if i & 0b100 else 'lz', + }) + for i in range(8) + ]) def get_params(self): return { @@ -192,6 +233,99 @@ def get_params(self): } +def _chunk(iterable, length, fn=None): + if not fn: + fn = lambda x: x + + items = [] + it = iter(iterable) + while True: + try: + items.append(fn(next(it))) + except StopIteration: + if items: + raise ValueError( + "Iterable did not have a multiple of {} items ({} spare)".format(length, len(items)) + ) + else: + return + else: + if len(items) == length: + yield tuple(items) + items = [] + + +def _x3d_to_points(x3d, fn=None): + indexed_triangle_set = ET.fromstring(x3d) + assert indexed_triangle_set.tag == "IndexedTriangleSet" + assert len(indexed_triangle_set) == 1 + + coordinate = indexed_triangle_set[0] + assert coordinate.tag == "Coordinate" + assert len(coordinate) == 0 + points_str = coordinate.attrib["point"] + + for item in _chunk(points_str.split(' '), 3, fn): + yield item + + +def _x3d_to_stl_ascii(x3d): + solid_fmt = """ +solid +{} +endsolid + """.strip() + facet_fmt = """ +facet normal 0 0 0 +outer loop +{} +endloop +endfacet + """.strip() + vertex_fmt = "vertex {} {} {}" + + triangle_strs = [] + for triangle in _chunk(_x3d_to_points(x3d), 3): + vertices = '\n'.join(vertex_fmt.format(*point) for point in triangle) + triangle_strs.append(facet_fmt.format(vertices)) + + return solid_fmt.format('\n'.join(triangle_strs)) + + +class InvalidSTLError(ValueError): + pass + + +def _stl_ascii_to_indexed_triangles(stl_str): + stl_items = stl_str.strip().split() + if stl_items[0] != "solid" or "endsolid" not in stl_items[-2:]: + raise InvalidSTLError("Malformed solid header/ footer") + start = 1 if stl_items[1] == "facet" else 2 + stop = -1 if stl_items[-2] == "endfacet" else -2 + vertices = [] + triangles = [] + for facet in _chunk(stl_items[start:stop], 21): + if any([ + facet[:2] != ("facet", "normal"), + facet[5:7] != ("outer", "loop"), + facet[-2:] != ("endloop", "endfacet") + ]): + raise InvalidSTLError("Malformed facet/loop header/footer") + + this_triangle = [] + for vertex in _chunk(facet[7:-2], 4): + if vertex[0] != "vertex": + raise InvalidSTLError("Malformed vertex") + vertex_id = len(vertices) + vertices.append([float(item) for item in vertex[1:]]) + this_triangle.append(vertex_id) + if len(this_triangle) != 3: + raise InvalidSTLError("Expected triangle, got {} points".format(this_triangle)) + triangles.append(this_triangle) + + return vertices, triangles + + volume_type = { "box": BoxVolume, "trimesh": TriangleMeshVolume @@ -218,10 +352,31 @@ def volume_collection(request, project_id): # FIXME: Parsing our PostGIS geometry with GeoDjango doesn't work # anymore since Django 1.8. Therefore, the geometry fields isn't read. # See: https://github.com/catmaid/CATMAID/issues/1250 - fields = ('id', 'name', 'comment', 'user', 'editor', 'project', - 'creation_time', 'edition_time') - volumes = Volume.objects.filter(project_id=project_id).values(*fields) - return Response(volumes) + + cursor = connection.cursor() + cursor.execute(""" + SELECT v.id, v.name, v.comment, v.user_id, v.editor_id, v.project_id, + v.creation_time, v.edition_time, + JSON_AGG(ann.name) FILTER (WHERE ann.name IS NOT NULL) AS annotations + FROM catmaid_volume v + LEFT JOIN volume_class_instance vci ON vci.volume_id = v.id + LEFT JOIN class_instance_class_instance cici + ON cici.class_instance_a = vci.class_instance_id + LEFT JOIN class_instance ann ON ann.id = cici.class_instance_b + WHERE v.project_id = %(pid)s + AND ( + cici.relation_id IS NULL OR + cici.relation_id = ( + SELECT id FROM relation + WHERE project_id = %(pid)s AND relation_name = 'annotated_with' + ) + ) + GROUP BY v.id + """, {'pid': project_id}) + return JsonResponse({ + 'columns': [r[0] for r in cursor.description], + 'data': cursor.fetchall() + }) def get_volume_details(project_id, volume_id): cursor = connection.cursor() @@ -294,7 +449,17 @@ def remove_volume(request, project_id, volume_id): raise Exception("You don't have permissions to delete this volume") cursor.execute(""" - DELETE FROM catmaid_volume WHERE id=%s + WITH v AS ( + DELETE FROM catmaid_volume WHERE id=%s RETURNING id + ), vci AS ( + DELETE FROM volume_class_instance + USING v + WHERE volume_id = v.id + RETURNING class_instance_id + ) + DELETE FROM class_instance + USING vci + WHERE id = vci.class_instance_id; """, (volume_id,)) return Response({ @@ -441,6 +606,101 @@ def add_volume(request, project_id): "volume_id": volume_id }) + +@api_view(['POST']) +@requires_user_role([UserRole.Import]) +def import_volumes(request, project_id): + """Import triangle mesh volumes from an uploaded files. + + Currently only STL representation is supported. + --- + consumes: multipart/form-data + parameters: + - name: file + required: true + description: > + Triangle mesh file to import. Multiple files can be provided, with + each being imported as a mesh named by its base filename. + paramType: body + dataType: File + type: + '{base_filename}': + description: ID of the volume created from this file + type: integer + required: true + """ + fnames_to_id = dict() + for uploadedfile in request.FILES.values(): + if uploadedfile.size > settings.IMPORTED_SKELETON_FILE_MAXIMUM_SIZE: # todo: use different setting + return HttpResponse( + 'File too large. Maximum file size is {} bytes.'.format(settings.IMPORTED_SKELETON_FILE_MAXIMUM_SIZE), + status=413) + + filename = uploadedfile.name + name, extension = os.path.splitext(filename) + if extension.lower() == ".stl": + stl_str = uploadedfile.read().decode('utf-8') + + try: + vertices, triangles = _stl_ascii_to_indexed_triangles(stl_str) + except InvalidSTLError as e: + raise ValueError("Invalid STL file ({})".format(str(e))) + + mesh = TriangleMeshVolume( + project_id, request.user.id, + {"type": "trimesh", "title": name, "mesh": [vertices, triangles]} + ) + fnames_to_id[filename] = mesh.save() + else: + return HttpResponse('File type "{}" not understood. Known file types: stl'.format(extension), status=415) + + return JsonResponse(fnames_to_id) + + +class AnyRenderer(renderers.BaseRenderer): + """A DRF renderer that returns the data directly with a wildcard media type. + + This is useful for bypassing response content type negotiation. + """ + media_type = '*/*' + + def render(self, data, media_type=None, renderer_context=None): + return data + + +@api_view(['GET']) +@renderer_classes((AnyRenderer,)) +@requires_user_role([UserRole.Browse]) +def export_volume(request, project_id, volume_id, extension): + """Export volume as a triangle mesh file. + + The extension of the endpoint and `ACCEPT` header media type are both used + to determine the format of the export. + + Supported formats by extension and media type: + ##### STL + - `model/stl`, `model/x.stl-ascii`: ASCII STL + + """ + acceptable = { + 'stl': ['model/stl', 'model/x.stl-ascii'], + } + if extension.lower() in acceptable: + media_types = request.META.get('HTTP_ACCEPT', '').split(',') + for media_type in media_types: + if media_type in acceptable[extension]: + details = get_volume_details(project_id, volume_id) + ascii_details = _x3d_to_stl_ascii(details['mesh']) + response = HttpResponse(content_type=media_type) + response.write(ascii_details) + return response + return HttpResponse('Media types "{}" not understood. Known types for {}: {}'.format( + ', '.join(media_types), extension, ', '.join(acceptable[extension])), status=415) + else: + return HttpResponse('File type "{}" not understood. Known file types: {}'.format( + extension, ', '.join(acceptable.values())), status=415) + + @api_view(['GET']) @requires_user_role([UserRole.Browse]) def intersects(request, project_id, volume_id): @@ -490,3 +750,34 @@ def intersects(request, project_id, volume_id): return JsonResponse({ 'intersects': result[0] }) + +@api_view(['POST']) +@requires_user_role([UserRole.Browse]) +def get_volume_entities(request, project_id): + """Retrieve a mapping of volume IDs to entity (class instance) IDs. + --- + parameters: + - name: volume_ids + description: A list of volume IDs to map + paramType: query + """ + volume_ids = get_request_list(request.POST, 'volume_ids', map_fn=int) + + cursor = connection.cursor() + cursor.execute(""" + SELECT vci.volume_id, vci.class_instance_id + FROM volume_class_instance vci + JOIN UNNEST(%(volume_ids)s::int[]) volume(id) + ON volume.id = vci.volume_id + WHERE project_id = %(project_id)s + AND relation_id = ( + SELECT id FROM relation + WHERE relation_name = 'model_of' + AND project_id = %(project_id)s + ) + """, { + 'volume_ids': volume_ids, + 'project_id': project_id + }) + + return JsonResponse(dict(cursor.fetchall())) diff --git a/django/applications/catmaid/management/commands/catmaid_check_db_integrity.py b/django/applications/catmaid/management/commands/catmaid_check_db_integrity.py index 78883eb624..8c7d552f1b 100644 --- a/django/applications/catmaid/management/commands/catmaid_check_db_integrity.py +++ b/django/applications/catmaid/management/commands/catmaid_check_db_integrity.py @@ -1,12 +1,26 @@ # -*- coding: utf-8 -*- +import logging import sys - +import numpy as np import progressbar from django.core.management.base import BaseCommand, CommandError from django.db import connection from catmaid.models import Project +from catmaid.util import str2bool + + +logger = logging.getLogger(__name__) + +winding_check_enabled = True +try: + from trimesh import Trimesh + from trimesh.grouping import merge_vertices_hash +except ImportError: + logger.warn('Optional depedency "trimesh" not found. Won\'t be able to check volume triange winding.') + winding_check_enabled = False + class Command(BaseCommand): help = ''' @@ -15,6 +29,10 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--project_id', nargs='*', type=int, default=[]) + parser.add_argument("--tracing", type=str2bool, nargs='?', + const=True, default=True, help="Check tracing data.") + parser.add_argument("--volumes", type=str2bool, nargs='?', + const=True, default=True, help="Check volumes data.") def handle(self, *args, **options): project_ids = options['project_id'] @@ -23,17 +41,29 @@ def handle(self, *args, **options): passed = True for project_id in project_ids: - passed = passed and self.check_project(project_id) + passed = passed and self.check_project(project_id, options) if not passed: sys.exit(1) - def check_project(self, project_id): + def check_project(self, project_id, options): if not Project.objects.filter(id=project_id).exists(): raise CommandError('Project with id %s does not exist.' % project_id) - project_passed = True self.stdout.write('Checking integrity of project %s' % project_id) + passed = True + if options['tracing']: + passed = passed and self.check_tracing_data(project_id) + + if options['volumes']: + passed = passed and self.check_volumes(project_id) + + self.stdout.write('') + + return passed + + + def check_tracing_data(self, project_id): self.stdout.write('Check that no connected treenodes are in different skeletons...', ending='') cursor = connection.cursor() cursor.execute(''' @@ -102,9 +132,108 @@ def check_project(self, project_id): project_passed = False row = cursor.fetchone() self.stdout.write('FAILED: node %s in skeleton %s has no path to root' % row) + if test_passed: self.stdout.write('OK') - self.stdout.write('') - return project_passed + def check_volumes(self, project_id): + passed = True + self.stdout.write('Check if all meshes consist only of triangles...', ending='') + cursor = connection.cursor() + cursor.execute(""" + SELECT volume_id, triangle_id, path, txtpoints + FROM ( + SELECT volume_id, + (v.gdump).path[1], + array_agg((v.gdump).path order by (v.gdump).path[3] ASC), + array_agg((v.gdump).geom order by (v.gdump).path[3] ASC) as points, + array_agg(ST_AsText((v.gdump).geom) ORDER BY (v.gdump).path[3] ASC) as txtpoints + FROM ( + SELECT volume_id, gdump + FROM ( + SELECT v.id AS volume_id, + ST_DumpPoints(geometry) AS gdump + FROM catmaid_volume v + ) v(volume_id, gdump) + ) v(volume_id, gdump) + GROUP BY v.volume_id, (v.gdump).path[1] + ) triangle(volume_id, triangle_id, path, points, txtpoints) + WHERE array_length(points, 1) <> 4 + OR ST_X(points[1]) <> ST_X(points[4]) + OR ST_Y(points[1]) <> ST_Y(points[4]) + OR ST_Z(points[1]) <> ST_Z(points[4]); + """) + non_triangles = list(cursor.fetchall()) + n_non_triangles = len(non_triangles) + if n_non_triangles > 0: + self.stdout.write('FAILED: found {} non-triangle meshes in project {}'.format( + n_non_triangles, project_id)) + self.stdout.write('\tThe following volumes contain those geometries: {}'.format( + ', '.join(nt[0] for nt in non_triangles))) + passed = False + else: + self.stdout.write('OK') + passed = passed and True + + + self.stdout.write('Check if all triangles have the same orientation...', ending='') + if winding_check_enabled: + cursor.execute(""" + SELECT volume_id, triangle_id, points + FROM ( + SELECT volume_id, + (v.gdump).path[1], + /* Points need to be ordered by index to be comparable. */ + array_agg(ARRAY[ST_X((v.gdump).geom), ST_Y((v.gdump).geom), ST_Z((v.gdump).geom)] order by (v.gdump).path[ 3] ASC) as points + FROM ( + SELECT volume_id, gdump + FROM ( + SELECT v.id AS volume_id, + ST_DumpPoints(geometry) AS gdump + FROM catmaid_volume v + ) v(volume_id, gdump) + ) v(volume_id, gdump) + GROUP BY v.volume_id, (v.gdump).path[1] + ) triangle(volume_id, triangle_id, points) + WHERE array_length(points, 1) = 4; + """) + volumes = {} + for tri in cursor.fetchall(): + entry = volumes.get(tri[0]) + if not entry: + entry = { + 'volume_id': tri[0], + 'vertices': [], + 'faces': [], + } + volumes[tri[0]] = entry + vertices = entry['vertices'] + faces = entry['faces'] + vertex_offset = len(vertices) + vertices.extend(tri[2]) + faces.append([vertex_offset, vertex_offset + 1, vertex_offset + 2]) + + volumes_with_inconsistent_winding = [] + for volume_id, details in volumes.items(): + mesh = Trimesh(vertices=np.array(vertices), faces=np.array(faces), + process=False) + # Merge all vertices in trimeshs + merge_vertices_hash(mesh) + # Check if the winding is consistent + if not mesh.is_winding_consistent: + volumes_with_inconsistent_winding.append(volume_id) + details['mesh'] = mesh + + if volumes_with_inconsistent_winding: + self.stdout.write('FAILED: The following volumes have an ' + + 'inconsistent winding: {}'.format(', '.join( + volumes_with_inconsistent_winding))) + else: + self.stdout.write('OK') + + passed = passed and not volumes_with_inconsistent_winding + else: + self.stdout.write('Not enabled (pip intall trimesh to enable)') + + return passed diff --git a/django/applications/catmaid/migrations/0049_volume_tin_representation.py b/django/applications/catmaid/migrations/0049_volume_tin_representation.py new file mode 100644 index 0000000000..1b1fc572f3 --- /dev/null +++ b/django/applications/catmaid/migrations/0049_volume_tin_representation.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-17 15:45 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +# Construct a PostGIS TIN representation of a bounding box +bb_vertices = """ + (({0}, {2}, {1}, {0})), + (({1}, {2}, {3}, {1})), + + (({0}, {1}, {5}, {0})), + (({0}, {5}, {4}, {0})), + + (({2}, {6}, {7}, {2})), + (({2}, {7}, {3}, {2})), + + (({4}, {7}, {6}, {4})), + (({4}, {5}, {7}, {4})), + + (({0}, {6}, {2}, {0})), + (({0}, {4}, {6}, {0})), + + (({1}, {3}, {5}, {1})), + (({3}, {7}, {5}, {3})) +""".format(*[ + '%{a}$s %{b}$s %{c}$s'.format(**{ + 'a': 1 if i & 0b001 else 2, + 'b': 3 if i & 0b010 else 4, + 'c': 5 if i & 0b100 else 6, + }) + for i in range(8) +]) + +forward = """ + DO $$ + BEGIN + -- Make sure we only deal with polyhedeal surfaces and TINs + IF (SELECT COUNT(*) FROM catmaid_volume + WHERE ST_GeometryType(geometry) NOT IN ('ST_PolyhedralSurface', 'ST_Tin') + LIMIT 1) <> 0 + THEN + RAISE EXCEPTION 'Only geometries of type ST_PolyhedralSurface and ' + 'ST_Tin are supported by CATMAID. Please fix volumes manually.'; + END IF; + + -- Make sure that all polyhedral surfaces have 30 vertices and six faces, + -- in which case we assume it is a simple box. + IF (SELECT COUNT(*) FROM catmaid_volume + WHERE ST_GeometryType(geometry) = 'ST_PolyhedralSurface' + AND (ST_NPoints(geometry) <> 30 OR ST_NumGeometries(geometry) <> 6) + LIMIT 1) <> 0 + THEN + RAISE EXCEPTION 'All polyhedral surfaces need to be boxes, i.e. ' + 'have 30 vertices and 6 faces. Please fix volumes manually.'; + END IF; + END + $$; + + SELECT disable_history_tracking_for_table('catmaid_volume'::regclass, + get_history_table_name('catmaid_volume'::regclass)); + SELECT drop_history_view_for_table('catmaid_volume'::regclass); + + -- Convert polyhedral surfaces to TINs, assuming that we only deal with + -- boxes. + UPDATE catmaid_volume + SET geometry = ST_GeomFromEWKT(FORMAT('TINZ ({bb_vertices})', + ST_XMax(geometry), + ST_XMin(geometry), + ST_YMax(geometry), + ST_YMin(geometry), + ST_ZMax(geometry), + ST_ZMin(geometry))) + WHERE ST_GeometryType(geometry) = 'ST_PolyhedralSurface'; + + UPDATE catmaid_volume__history + SET geometry = ST_GeomFromEWKT(FORMAT('TINZ ({bb_vertices})', + ST_XMax(geometry), + ST_XMin(geometry), + ST_YMax(geometry), + ST_YMin(geometry), + ST_ZMax(geometry), + ST_ZMin(geometry))) + WHERE ST_GeometryType(geometry) = 'ST_PolyhedralSurface'; + + ALTER TABLE catmaid_volume + ALTER COLUMN geometry TYPE Geometry(TINZ); + + ALTER TABLE catmaid_volume__history + ALTER COLUMN geometry TYPE Geometry(TINZ); + + SELECT create_history_view_for_table('catmaid_volume'::regclass); + SELECT enable_history_tracking_for_table('catmaid_volume'::regclass, + get_history_table_name('catmaid_volume'::regclass), FALSE); +""".format(bb_vertices=bb_vertices) + +backward = """ + SELECT disable_history_tracking_for_table('catmaid_volume'::regclass, + get_history_table_name('catmaid_volume'::regclass)); + SELECT drop_history_view_for_table('catmaid_volume'::regclass); + + ALTER TABLE catmaid_volume + ALTER COLUMN geometry TYPE Geometry(GeometryZ); + + ALTER TABLE catmaid_volume__history + ALTER COLUMN geometry TYPE Geometry(GeometryZ); + + SELECT create_history_view_for_table('catmaid_volume'::regclass); + SELECT enable_history_tracking_for_table('catmaid_volume'::regclass, + get_history_table_name('catmaid_volume'::regclass), FALSE); +""" + +class Migration(migrations.Migration): + + dependencies = [ + ('catmaid', '0048_add_pointcloud_permissions'), + ] + + operations = [ + migrations.RunSQL(forward, backward) + ] diff --git a/django/applications/catmaid/migrations/0050_create_volume_class_and_instances.py b/django/applications/catmaid/migrations/0050_create_volume_class_and_instances.py new file mode 100644 index 0000000000..81eb6a1626 --- /dev/null +++ b/django/applications/catmaid/migrations/0050_create_volume_class_and_instances.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-20 14:27 +import logging + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + +from catmaid.apps import get_system_user + + +logger = logging.getLogger(__name__) + + +def forwards(apps, schema_editor): + """Make sure all required class and relations are existing for all + projects. We can't use the regular model classes, but have to get + them through the migration system. + """ + from catmaid.control.project import validate_project_setup + + Class = apps.get_model('catmaid', 'Class') + Project = apps.get_model('catmaid', 'Project') + Relation = apps.get_model('catmaid', 'Relation') + User = apps.get_model('auth', 'User') + ClientDatastore = apps.get_model('catmaid', 'ClientDatastore') + Volume = apps.get_model('catmaid', 'Volume') + + projects = Project.objects.all() + # If there are no projects, don't continue, because there is nothing to + # migrate. + if 0 == len(projects) or 0 == User.objects.count(): + return + + try: + system_user = get_system_user(User) + for p in projects: + validate_project_setup(p.id, system_user.id, True, Class, Relation, ClientDatastore) + except ImproperlyConfigured as e: + if Volume.objects.count() > 0: + logger.warn("Couldn't find a configured system user and will therefore " + "skip a configuration update of all existing projects. This is " + "okay during the initial setup of a CATMAID database. In this " + "case nothing needs to be done. Otherwise, please run " + "`manage.py catmaid_update_project_configuration` manually " + "after this migration call is finished and rerun this migration.") + raise e + +forward_create_relations = """ + CREATE TABLE volume_class_instance ( + volume_id bigint NOT NULL, + class_instance_id integer NOT NULL + ) + INHERITS (relation_instance); + + -- Volume Class Instance constraints + ALTER TABLE ONLY volume_class_instance + ADD CONSTRAINT volume_class_instance_pkey PRIMARY KEY (id); + + ALTER TABLE ONLY volume_class_instance + ADD CONSTRAINT volume_class_instance_sa_id + FOREIGN KEY (volume_id) + REFERENCES catmaid_volume(id) DEFERRABLE INITIALLY DEFERRED; + + ALTER TABLE ONLY volume_class_instance + ADD CONSTRAINT volume_class_instance_id_fkey + FOREIGN KEY (class_instance_id) + REFERENCES class_instance(id) DEFERRABLE INITIALLY DEFERRED; + + WITH new_cis AS ( + INSERT INTO class_instance (user_id, project_id, name, class_id) + SELECT + v.user_id, + v.project_id, + v.name, + c.id + FROM catmaid_volume v + JOIN class c ON (c.project_id = v.project_id AND c.class_name = 'volume') + RETURNING id, user_id, project_id, name + ) + INSERT INTO volume_class_instance (user_id, project_id, relation_id, volume_id, class_instance_id) + SELECT + v.user_id, + v.project_id, + r.id, + v.id, + ci.id + FROM catmaid_volume v + JOIN relation r ON (r.project_id = v.project_id AND r.relation_name = 'model_of') + JOIN new_cis ci ON ( + ci.user_id = v.user_id AND + ci.project_id = v.project_id AND + ci.name = v.name + ); + + -- Create history tables + SELECT create_history_table('volume_class_instance'::regclass, 'edition_time', 'txid'); +""" + +backward_create_relations = """ + SELECT disable_history_tracking_for_table('volume_class_instance'::regclass, + get_history_table_name('volume_class_instance'::regclass)); + SELECT drop_history_table('volume_class_instance'::regclass); + + DROP TABLE volume_class_instance; + + DELETE FROM class_instance_class_instance cici + USING class ca, class cb, class_instance cia, class_instance cib + WHERE (cici.class_instance_a = cia.id + OR cici.class_instance_b = cib.id) + AND cia.class_id = ca.id + AND cib.class_id = cb.id + AND ca.class_name = 'volume' + AND cb.class_name = 'volume'; + + DELETE FROM class_instance + USING class c + WHERE class_instance.class_id = c.id + AND c.class_name = 'volume'; + +""" + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('catmaid', '0049_volume_tin_representation'), + ] + + operations = [ + migrations.RunPython(forwards, migrations.RunPython.noop), + migrations.RunSQL( + forward_create_relations, + backward_create_relations, + [ + migrations.CreateModel( + name='VolumeClassInstance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_time', models.DateTimeField(default=django.utils.timezone.now)), + ('edition_time', models.DateTimeField(default=django.utils.timezone.now)), + ('class_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catmaid.ClassInstance')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catmaid.Project')), + ('relation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catmaid.Relation')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('volume', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catmaid.Volume')), + ], + options={ + 'db_table': 'volume_class_instance', + }, + ), + ] + ), + ] diff --git a/django/applications/catmaid/models.py b/django/applications/catmaid/models.py index dc3ffd096e..73586bed13 100644 --- a/django/applications/catmaid/models.py +++ b/django/applications/catmaid/models.py @@ -719,6 +719,17 @@ class Volume(UserFocusedModel): geometry = spatial_models.GeometryField(dim=3, srid=0) +class VolumeClassInstance(UserFocusedModel): + # Repeat the columns inherited from 'relation_instance' + relation = models.ForeignKey(Relation, on_delete=models.CASCADE) + # Now new columns: + volume = models.ForeignKey(Volume, on_delete=models.CASCADE) + class_instance = models.ForeignKey(ClassInstance, on_delete=models.CASCADE) + + class Meta: + db_table = "volume_class_instance" + + class RegionOfInterest(UserFocusedModel): # Repeat the columns inherited from 'location' editor = models.ForeignKey(User, on_delete=models.CASCADE, diff --git a/django/applications/catmaid/static/js/WindowMaker.js b/django/applications/catmaid/static/js/WindowMaker.js index 17c26317f6..50b52f861a 100644 --- a/django/applications/catmaid/static/js/WindowMaker.js +++ b/django/applications/catmaid/static/js/WindowMaker.js @@ -708,49 +708,6 @@ var WindowMaker = new function() } } - // Update volume list - var initVolumeList = function() { - return CATMAID.Volumes.listAll(project.id).then(function(json) { - var volumes = json.sort(function(a, b) { - return CATMAID.tools.compareStrings(a.name, b.name); - }).map(function(volume) { - return { - title: volume.name, - value: volume.id - }; - }); - var selectedVolumes = WA.getLoadedVolumeIds(); - // Create actual element based on the returned data - var node = DOM.createCheckboxSelect('Volumes', volumes, - selectedVolumes, true, function(row, id, visible) { - let loadedVolume = WA.loadedVolumes.get(id); - let faces, color, alpha, subdiv, bb; - if (loadedVolume) { - faces = loadedVolume.faces; - color = loadedVolume.color; - alpha = loadedVolume.opacity; - subdiv = loadedVolume.subdiv; - bb = loadedVolume.boundingBox; - } - setVolumeEntryVisible(row, id, visible, faces, color, alpha, subdiv, bb); - }); - - // Add a selection handler - node.onchange = function(e) { - var visible = e.target.checked; - var volumeId = parseInt(e.target.value, 10); - WA.showVolume(volumeId, visible, undefined, undefined, - o.meshes_faces) - .catch(CATMAID.handleError); - - setVolumeEntryVisible(e.target.closest('li'), volumeId, visible, - o.meshes_faces, o.meshes_color, o.meshes_opacity, - o.meshes_subdiv, o.meshes_boundingbox); - }; - return node; - }); - }; - // Update landmark list var initLandmarkList = function() { return CATMAID.Landmarks.listGroups(project.id).then(function(json) { @@ -859,9 +816,29 @@ var WindowMaker = new function() // Create async selection and wrap it in container to have handle on initial // DOM location - var volumeSelection = DOM.createAsyncPlaceholder(initVolumeList()); - var volumeSelectionWrapper = document.createElement('span'); - volumeSelectionWrapper.appendChild(volumeSelection); + var volumeSelectionWrapper = CATMAID.createVolumeSelector({ + mode: "checkbox", + selectedVolumeIds: WA.getLoadedVolumeIds(), + select: function(volumeId, visible, element){ + WA.showVolume(volumeId, visible, undefined, undefined, o.meshes_faces) + .catch(CATMAID.handleError); + + setVolumeEntryVisible(element.closest('li'), volumeId, visible, + o.meshes_faces, o.meshes_color, o.meshes_opacity, + o.meshes_subdiv, o.meshes_boundingbox); + }, + rowCallback: function(row, id, visible) { + let loadedVolume = WA.loadedVolumes.get(id); + let faces, color, alpha, subdiv, bb; + if (loadedVolume) { + faces = loadedVolume.faces; + color = loadedVolume.color; + alpha = loadedVolume.opacity; + subdiv = loadedVolume.subdiv; + bb = loadedVolume.boundingBox; + } + } + }); // Create async selection and wrap it in container to have handle on initial // DOM location @@ -877,11 +854,7 @@ var WindowMaker = new function() // Replace volume selection wrapper children with new select var refreshVolumeList = function() { - while (0 !== volumeSelectionWrapper.children.length) { - volumeSelectionWrapper.removeChild(volumeSelectionWrapper.children[0]); - } - var volumeSelection = DOM.createAsyncPlaceholder(initVolumeList()); - volumeSelectionWrapper.appendChild(volumeSelection); + volumeSelectionWrapper.refresh(WA.getLoadedVolumeIds()); }; // Replace point cloud selection wrapper children with new select diff --git a/django/applications/catmaid/static/js/helpers/volumes.js b/django/applications/catmaid/static/js/helpers/volumes.js new file mode 100644 index 0000000000..662f8c6c3b --- /dev/null +++ b/django/applications/catmaid/static/js/helpers/volumes.js @@ -0,0 +1,77 @@ +/* -*- mode: espresso; espresso-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set softtabstop=2 shiftwidth=2 tabstop=2 expandtab: */ + +(function (CATMAID) { + + "use strict"; + + // Update volume list + let initVolumeList = function (options, newSelectedIds) { + return CATMAID.Volumes.listAll(project.id).then(function (json) { + let volumes = json.sort(function (a, b) { + return CATMAID.tools.compareStrings(a.name, b.name); + }).map(function (volume) { + return { + title: volume.name, + value: volume.id + }; + }); + if (options.mode === "radio") { + let selectedVolume = newSelectedIds || options.selectedVolumeIds; + if (selectedVolume.length > 1){ + throw new CATMAID.ValueError("Radio select only takes one selected volume"); + } + // Create actual element based on the returned data + let node = CATMAID.DOM.createRadioSelect('Volumes', volumes, + selectedVolume[0], true); + // Add a selection handler + node.onchange = function (e) { + let volumeId = e.target.value; + let selected = true; + + if (CATMAID.tools.isFn(options.select)) { + options.select(volumeId, selected, e.target); + } + }; + return node; + } else { + let selectedVolumes = newSelectedIds || options.selectedVolumeIds; + // Create actual element based on the returned data + let node = CATMAID.DOM.createCheckboxSelect('Volumes', volumes, + selectedVolumes, true, options.rowCallback); + + // Add a selection handler + node.onchange = function (e) { + let selected = e.target.checked; + let volumeId = parseInt(e.target.value, 10); + + if (CATMAID.tools.isFn(options.select)) { + options.select(volumeId, selected, e.target); + } + }; + return node; + } + }); + }; + + CATMAID.createVolumeSelector = function (options) { + var volumeSelectionWrapper = document.createElement('span'); + let volumeSelection; + if (options.label){ + volumeSelection = CATMAID.DOM.createLabeledAsyncPlaceholder(options.label, initVolumeList(options), options.title); + } else { + volumeSelection = CATMAID.DOM.createAsyncPlaceholder(initVolumeList(options)); + } + volumeSelectionWrapper.appendChild(volumeSelection); + volumeSelectionWrapper.refresh = function(newSelectedIds){ + while (0 !== volumeSelectionWrapper.children.length) { + volumeSelectionWrapper.removeChild(volumeSelectionWrapper.children[0]); + } + var volumeSelection = CATMAID.DOM.createAsyncPlaceholder(initVolumeList(options, newSelectedIds)); + volumeSelectionWrapper.appendChild(volumeSelection); + }; + return volumeSelectionWrapper; + }; + + +})(CATMAID); \ No newline at end of file diff --git a/django/applications/catmaid/static/js/tools.js b/django/applications/catmaid/static/js/tools.js index 867807d279..d498bdc840 100644 --- a/django/applications/catmaid/static/js/tools.js +++ b/django/applications/catmaid/static/js/tools.js @@ -822,4 +822,14 @@ CATMAID.tools = CATMAID.tools || {}; return path.substring(start, end); }; + /** + * Create an object from matched arrays of keys and values. + */ + tools.buildObject = function (keys, values) { + return keys.reduce(function (obj, k, i) { + obj[k] = values[i]; + return obj; + }, {}); + }; + })(CATMAID.tools); diff --git a/django/applications/catmaid/static/js/widgets/settings.js b/django/applications/catmaid/static/js/widgets/settings.js index 28e9e83e27..ffc5cb17f9 100644 --- a/django/applications/catmaid/static/js/widgets/settings.js +++ b/django/applications/catmaid/static/js/widgets/settings.js @@ -1735,42 +1735,19 @@ var dsTracingWarnings = CATMAID.DOM.addSettingsContainer(ds, "Warnings", true); - // Volume warning - let initVolumeList = function() { - return CATMAID.Volumes.listAll(project.id) - .then(function(json) { - var volumes = json.sort(function(a, b) { - return CATMAID.tools.compareStrings(a.name, b.name); - }).map(function(volume) { - return { - title: volume.name + " (#" + volume.id + ")", - value: volume.id - }; - }); - var selectedVolumeId = SkeletonAnnotations.getNewNodeVolumeWarning(); - // Create actual element based on the returned data - var node = CATMAID.DOM.createRadioSelect('Volumes', volumes, - selectedVolumeId, true); - // Add a selection handler - node.onchange = function(e) { - let volumeId = null; - if (e.srcElement.value !== "none") { - volumeId = parseInt(e.srcElement.value, 10); - } - // Remove existing handler and new one if selected - SkeletonAnnotations.setNewNodeVolumeWarning(volumeId); - }; - - return node; - }); - }; - // Create async selection and wrap it in container to have handle on initial // DOM location - var volumeSelectionSetting = CATMAID.DOM.createLabeledAsyncPlaceholder( - "New nodes not in volume", initVolumeList(), - "A warning will be shown when new nodes are created outside of the selected volume"); - dsTracingWarnings.append(volumeSelectionSetting); + var volumeSelectionWrapper = CATMAID.createVolumeSelector({ + mode: "radio", + label: "New nodes not in volume", + title: "A warning will be shown when new nodes are created outside of the selected volume", + selectedVolumeIds: [SkeletonAnnotations.getNewNodeVolumeWarning()], + select: function(volumeId, selected, element){ + // Remove existing handler and new one if selected + SkeletonAnnotations.setNewNodeVolumeWarning(element.value !== "none"? volumeId : null); + } + }); + dsTracingWarnings.append(volumeSelectionWrapper); // Skeleton length warning diff --git a/django/applications/catmaid/static/js/widgets/volumewidget.js b/django/applications/catmaid/static/js/widgets/volumewidget.js index 8bb54db73c..48c605dcb5 100644 --- a/django/applications/catmaid/static/js/widgets/volumewidget.js +++ b/django/applications/catmaid/static/js/widgets/volumewidget.js @@ -67,16 +67,30 @@ if (0 === files.length) { CATMAID.error("Choose at least one file!"); } else { - Array.from(files).forEach(this.addVolumeFromFile); + this.addVolumesFromSTL(Array.from(files).filter(function(file){ + if (file.name.endsWith("stl")){ + return true; + } else { + this.addVolumeFromFile(file).catch(CATMAID.handleError); + } + },this)).catch(CATMAID.handleError); } }).bind(this)); + hiddenFileButton.setAttribute('multiple', true); controls.appendChild(hiddenFileButton); var openFile = document.createElement('button'); + openFile.setAttribute('title','Supports Json and ascii-stl files'); openFile.appendChild(document.createTextNode('Add from file')); openFile.onclick = hiddenFileButton.click.bind(hiddenFileButton); controls.appendChild(openFile); + var annotate = document.createElement('button'); + annotate.appendChild(document.createTextNode('Annotate')); + annotate.setAttribute('title', 'Annotate all selected volumes'); + annotate.onclick = this.annotateSelectedVolumes.bind(this); + controls.appendChild(annotate); + let self = this; CATMAID.DOM.appendNumericField( controls, @@ -136,7 +150,7 @@ table.style.width = "100%"; var header = table.createTHead(); var hrow = header.insertRow(0); - var columns = ['Name', 'Id', 'Comment', 'User', 'Creation time', + var columns = ['', 'Name', 'Id', 'Comment', 'Annotations', 'User', 'Creation time', 'Editor', 'Edition time', 'Action']; columns.forEach(function(c) { hrow.insertCell().appendChild(document.createTextNode(c)); @@ -148,23 +162,49 @@ container.appendChild(tableContainer); this.datatable = $(table).DataTable({ lengthMenu: [CATMAID.pageLengthOptions, CATMAID.pageLengthLabels], - ajax: { - url: CATMAID.makeURL(project.id + "/volumes/"), - dataSrc: "" + ajax: function(data, callback, settings) { + + CATMAID.fetch(project.id + "/volumes/") + .then(function(volumeData) { + let volumes = volumeData.data.map(function(volume) { + return new CATMAID.Volume(CATMAID.tools.buildObject(volumeData.columns, volume)); + }); + callback({ + draw: data.draw, + data: volumes + }); + }) + .catch(CATMAID.handleError); }, columns: [ - {data: "name"}, + { + render: function(data, type, row, meta) { + return ''; + } + }, + {data: "title"}, {data: "id"}, {data: "comment"}, { - data: "user", + data: "annotations", + render: function (data, type, row, meta) { + if (type === 'display') { + return data.join(', '); + } else { + return data; + } + } + }, + { + data: "user_id", render: function(data, type, row, meta) { return CATMAID.User.safe_get(data).login; } }, {data: "creation_time"}, { - data: "editor", + data: "editor_id", render: function(data, type, row, meta) { return CATMAID.User.safe_get(data).login; } @@ -175,9 +215,16 @@ orderable: false, defaultContent: 'Remove ' + 'List skeletons ' + - 'List connectors' + 'List connectors' + + 'Export STL' } ], + }) + .on('change', 'input[data-role=select]', function() { + var table = $(this).closest('table'); + var tr = $(this).closest('tr'); + var data = $(table).DataTable().row(tr).data(); + data.selected = this.checked; }); // Remove volume if 'remove' was clicked @@ -294,9 +341,25 @@ return false; }); + // Connector intersection list + $(table).on('click', 'a[data-action="export-STL"]', function() { + var tr = $(this).closest("tr"); + var volume = self.datatable.row(tr).data(); + var headers = {Accept: ['model/x.stl-ascii', 'model/stl']}; + CATMAID.fetch("/" + project.id + "/volumes/" + volume.id + "/export.stl", "GET", undefined, true, undefined, undefined, undefined, headers) + .then(function(volume_file) { + var blob = new Blob([volume_file], {type: 'model/x.stl-ascii'}); + saveAs(blob, volume.name + '.stl'); + }) + .catch(CATMAID.handleError); + + // Prevent event from bubbling up. + return false; + }); + // Display a volume if clicked var self = this; - $(table).on('click', 'tbody td', function() { + $(table).on('dblclick', 'tbody td', function() { var tr = $(this).closest("tr"); var volume = self.datatable.row(tr).data(); self.loadVolume(volume.id) @@ -545,23 +608,41 @@ * @param {String} files The file to load */ VolumeManagerWidget.prototype.addVolumeFromFile = function(file) { - var self = this; - var reader = new FileReader(); - reader.onload = function(e) { - var volumes = JSON.parse(e.target.result); - // Try to load volumes and record invalid ones - var invalidVolumes = volumes.filter(function(v) { - var volumeType = volumeTypes[v.type]; - var properties = v.properties; - if (volumeType && properties) { - volumeType.createVolume(properties); - } else { - // Return true for invalid volume types - return !volumeType; - } - }); - }; - reader.readAsText(file); + return new Promise(function(resolve, reject) { + var self = this; + var reader = new FileReader(); + reader.onload = function(e) { + var volumes = JSON.parse(e.target.result); + // Try to load volumes and record invalid ones + var invalidVolumes = volumes.filter(function(v) { + var volumeType = volumeTypes[v.type]; + var properties = v.properties; + if (volumeType && properties) { + volumeType.createVolume(properties); + } else { + // Return true for invalid volume types + return !volumeType; + } + }); + }; + reader.readAsText(file); + }); + }; + + VolumeManagerWidget.prototype.addVolumesFromSTL = function(files) { + var self = this; + var data = new FormData(); + files.forEach(function(file){ + data.append(file.name, file, file.name); + }); + return new Promise(function(resolve, reject) { + CATMAID.fetch(project.id + "/volumes/import", "POST", data, undefined, undefined, undefined, undefined, {"Content-type" : null}) + .then(function(data){ + CATMAID.msg("success", Object.keys(data).length + " mesh(s) loaded"); + self.redraw(); + }) + .catch(CATMAID.handleError); + }); }; /** @@ -572,6 +653,39 @@ }; + /** + * Annotate all currently selected volumes. + */ + VolumeManagerWidget.prototype.annotateSelectedVolumes = function() { + if (!this.datatable) { + return; + } + + let allVolumes = this.datatable.rows({'search': 'applied' }).data().toArray(); + let selectedVolumeIds = allVolumes.filter(function(v) { + return v.selected; + }).map(function(v) { + return v.id; + }); + + if (selectedVolumeIds.length === 0) { + CATMAID.warn("No volumes selected"); + return; + } + + // Retrieve class instance IDs for volumes + CATMAID.fetch(project.id + '/volumes/entities/', 'POST', { + volume_ids: selectedVolumeIds + }) + .then(function(ciMapping) { + return CATMAID.annotate(Object.values(ciMapping)); + }) + .then(function() { + CATMAID.msg("Success", "Annotations added"); + }) + .catch(CATMAID.handleError); + }; + var getVolumeType = function(volume) { if (volume instanceof CATMAID.AlphaShapeVolume) { return "alphashape"; diff --git a/django/applications/catmaid/static/libs/catmaid/filter.js b/django/applications/catmaid/static/libs/catmaid/filter.js index dc28824713..9959705284 100644 --- a/django/applications/catmaid/static/libs/catmaid/filter.js +++ b/django/applications/catmaid/static/libs/catmaid/filter.js @@ -635,34 +635,17 @@ // Take all has no additional options }, 'volume': function(container, options) { - // Update volume list - var initVolumeList = function() { - return CATMAID.Volumes.listAll(project.id).then(function(json) { - var volumes = json.sort(function(a, b) { - return CATMAID.tools.compareStrings(a.name, b.name); - }).map(function(volume) { - return { - title: volume.name, - value: volume.id - }; - }); - var selectedVolume = options.volumeId; - // Create actual element based on the returned data - var node = CATMAID.DOM.createRadioSelect('Volumes', volumes, - selectedVolume, true); - // Add a selection handler - node.onchange = function(e) { - options.volumeId = e.target.value; - }; - return node; - }); - }; - // Create async selection and wrap it in container to have handle on initial // DOM location - var volumeSelection = CATMAID.DOM.createAsyncPlaceholder(initVolumeList()); - var volumeSelectionWrapper = document.createElement('span'); - $(container).append(volumeSelection); + var volumeSelectionWrapper = CATMAID.createVolumeSelector({ + mode: "radio", + selectedVolumeIds: [options.volumeId], + select: function(volumeId, selected, element){ + options.volumeId = volumeId; + } + }); + + container.appendChild(volumeSelectionWrapper); }, }; diff --git a/django/applications/catmaid/static/libs/catmaid/models/volumes.js b/django/applications/catmaid/static/libs/catmaid/models/volumes.js index ce3cec04e6..d25004df39 100644 --- a/django/applications/catmaid/static/libs/catmaid/models/volumes.js +++ b/django/applications/catmaid/static/libs/catmaid/models/volumes.js @@ -21,7 +21,11 @@ */ listAll: function(projectId) { var url = projectId + '/volumes/'; - return CATMAID.fetch(url, 'GET'); + return CATMAID.fetch(url, 'GET').then(function (volumes) { + return volumes.data.map(function (vol) { + return CATMAID.tools.buildObject(volumes.columns, vol); + }); + }); }, /** diff --git a/django/applications/catmaid/static/libs/catmaid/volumes.js b/django/applications/catmaid/static/libs/catmaid/volumes.js index e17195b0ac..98e1dfe63b 100644 --- a/django/applications/catmaid/static/libs/catmaid/volumes.js +++ b/django/applications/catmaid/static/libs/catmaid/volumes.js @@ -12,6 +12,15 @@ CATMAID.Volume = function(options) { options = options || {}; this.id = options.id || null; + this.project_id = options.project_id || null; + this.user_id = options.user_id || null; + this.editor_id = options.editor_id || null; + this.title = options.name || ''; + this.comment = options.comment || ''; + this.edition_time = options.edition_time || null; + this.creation_time = options.creation_time || null; + this.selected = options.selected || false; + this.annotations = options.annotations || []; }; CATMAID.Volume.prototype = {}; diff --git a/django/applications/catmaid/tests/apis/test_volume.py b/django/applications/catmaid/tests/apis/test_volume.py index 1318093ad2..7ff5d54655 100644 --- a/django/applications/catmaid/tests/apis/test_volume.py +++ b/django/applications/catmaid/tests/apis/test_volume.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- import json +import os + import pytz import datetime @@ -8,12 +10,17 @@ from dateutil.tz import tzutc from django.db import connection +from guardian.shortcuts import assign_perm from .common import CatmaidApiTestCase from catmaid.models import Volume from catmaid.control.volume import BoxVolume +FIXTURE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'fixtures') +CUBE_PATH = os.path.join(FIXTURE_DIR, 'cube.stl') + + class VolumeTests(CatmaidApiTestCase): def setUp(self): @@ -109,3 +116,51 @@ def test_volume_edit_comment_only(self): self.assertEqual(row[5], self.test_vol_1_data['name']) self.assertEqual(row[6], 'New comment') self.assertEqual(row[7], self.test_vol_1_data['geometry']) + + def test_import_trimesh_from_stl(self): + self.fake_authentication() + assign_perm('can_import', self.test_user, self.test_project) + with open(CUBE_PATH, 'rb') as f: + response = self.client.post( + "/{}/volumes/import".format(self.test_project_id), + {"cube.stl": f} + ) + + self.assertEqual(response.status_code, 200) + parsed_response = json.loads(response.content.decode('utf-8')) + self.assertEqual(len(parsed_response), 1) + self.assertTrue("cube.stl" in parsed_response) + + cube_id = parsed_response["cube.stl"] + + response = self.client.get("/{}/volumes/{}/".format(self.test_project_id, cube_id)) + + self.assertEqual(response.status_code, 200) + parsed_response = json.loads(response.content.decode('utf-8')) + self.assertEqual(parsed_response['name'], 'cube') + self.assertEqual(parsed_response['bbox'], { + 'min': {'x': 0, 'y': 0, 'z': 0}, + 'max': {'x': 1, 'y': 1, 'z': 1} + }) + + def test_export_stl(self): + self.fake_authentication() + assign_perm('can_import', self.test_user, self.test_project) + with open(CUBE_PATH, 'rb') as f: + response = self.client.post( + "/{}/volumes/import".format(self.test_project_id), + {"cube.stl": f} + ) + + self.assertEqual(response.status_code, 200) + parsed_response = json.loads(response.content.decode('utf-8')) + self.assertEqual(len(parsed_response), 1) + self.assertTrue("cube.stl" in parsed_response) + + cube_id = parsed_response["cube.stl"] + + response = self.client.get( + "/{}/volumes/{}/export.stl".format(self.test_project_id, cube_id), + HTTP_ACCEPT="model/x.stl-ascii,model/stl") + + self.assertEqual(response.status_code, 200) diff --git a/django/applications/catmaid/tests/fixtures/cube.stl b/django/applications/catmaid/tests/fixtures/cube.stl new file mode 100644 index 0000000000..b9904a3c7e --- /dev/null +++ b/django/applications/catmaid/tests/fixtures/cube.stl @@ -0,0 +1,86 @@ +solid cube + facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 0 + vertex 1 1 0 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 1 1 0 + vertex 1 0 0 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 0 0 1 + vertex 0 1 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 1 + vertex 0 1 0 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 0 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 1 0 1 + vertex 0 0 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 1 + vertex 1 0 1 + vertex 1 1 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 0 1 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 1 0 0 + vertex 1 1 0 + vertex 1 1 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 1 0 0 + vertex 1 1 1 + vertex 1 0 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 1 0 + vertex 0 1 1 + vertex 1 1 1 + endloop + endfacet + facet normal 0 0 0 + outer loop + vertex 0 1 0 + vertex 1 1 1 + vertex 1 1 0 + endloop + endfacet +endsolid cube diff --git a/django/applications/catmaid/tests/test_history_tables.py b/django/applications/catmaid/tests/test_history_tables.py index 960b8a42a5..32e41366df 100644 --- a/django/applications/catmaid/tests/test_history_tables.py +++ b/django/applications/catmaid/tests/test_history_tables.py @@ -94,6 +94,7 @@ def run(self, *args): 'point_set', 'image_data', 'pointcloud_image_data', + 'volume_class_instance', # Non-CATMAID tables 'auth_group', @@ -181,6 +182,7 @@ def run(self, *args): 'treenode__history', 'treenode_class_instance__history', 'treenode_connector__history', + 'volume_class_instance__history', # History tables of versioned non-CATMAID tables 'auth_group__history', diff --git a/django/applications/catmaid/urls.py b/django/applications/catmaid/urls.py index 1d1d4cfa5e..ef758e1679 100644 --- a/django/applications/catmaid/urls.py +++ b/django/applications/catmaid/urls.py @@ -494,8 +494,11 @@ urlpatterns += [ url(r'^(?P\d+)/volumes/$', volume.volume_collection), url(r'^(?P\d+)/volumes/add$', record_view("volumes.create")(volume.add_volume)), + url(r'^(?P\d+)/volumes/import$', volume.import_volumes), + url(r'^(?P\d+)/volumes/entities/$', volume.get_volume_entities), url(r'^(?P\d+)/volumes/(?P\d+)/$', volume.volume_detail), url(r'^(?P\d+)/volumes/(?P\d+)/intersect$', volume.intersects), + url(r'^(?P\d+)/volumes/(?P\d+)/export\.(?P\w+)', volume.export_volume), ] # Analytics diff --git a/django/applications/catmaid/util.py b/django/applications/catmaid/util.py index fc56d16b16..005dd3346c 100644 --- a/django/applications/catmaid/util.py +++ b/django/applications/catmaid/util.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import argparse import math from django.utils.encoding import python_2_unicode_compatible @@ -72,3 +73,11 @@ def is_collinear(a, b, c, between=False, eps=epsilon): return not (min(tx, ty, tz) < 0.0 or max(tx, ty, tz) > 1.0) else: return True + +def str2bool(v): + if v.lower() in ('yes', 'true', 't', 'y', '1'): + return True + elif v.lower() in ('no', 'false', 'f', 'n', '0'): + return False + else: + raise argparse.ArgumentTypeError('Boolean value expected.')