Module:CineMol/model

-- This is a port of CineMol to lua
-- CineMol https://github.com/moltools/CineMol was written by David Meijer, Marnix H. Medema & Justin J. J. van der Hooft and is MIT licensed
-- Please consider any edits made to this page as dual licensed MIT & CC-BY-SA 4.0

local p = {}

local geometry = require( 'Module:CineMol/geometry' )
local style = require( 'Module:CineMol/style' )
local cinemolsvg = require( 'Module:CineMol/svg' )
local calculate_convex_hull = require( 'Module:CineMol/fitting' ).calculate_convex_hull

local Cylinder = geometry.Cylinder 
local Line3D = geometry.Line3D 
local Point2D = geometry.Point2D 
local Point3D = geometry.Point3D 
local Sphere = geometry.Sphere 
local cylinder_intersects_with_cylinder = geometry.cylinder_intersects_with_cylinder 
local get_points_on_surface_cylinder = geometry.get_points_on_surface_cylinder 
local get_points_on_surface_sphere = geometry.get_points_on_surface_sphere 
local point_is_inside_cylinder = geometry.point_is_inside_cylinder 
local point_is_inside_sphere = geometry.point_is_inside_sphere 
local sphere_intersects_with_cylinder = geometry.sphere_intersects_with_cylinder 
local sphere_intersects_with_sphere = geometry.sphere_intersects_with_sphere 
local checkType = geometry.checkType

local Cartoon = style.Cartoon
local Color = style.Color
local Depiction = style.Depiction
local Fill = style.Fill
local FillStyle = style.FillStyle
local Glossy = style.Glossy
local LinearGradient = style.LinearGradient
local RadialGradient = style.RadialGradient
local Solid = style.Solid
local Wire = style.Wire

local Line2D = cinemolsvg.Line2D
local Polygon2D = cinemolsvg.Polygon2D
local Svg = cinemolsvg.Svg
local ViewBox = cinemolsvg.ViewBox

-- End of imports

-- Apply focal length to a 3D point.
function p.apply_focal_length(point, focal_length)
	checkType('apply_focal_length', 1, point, 'Point3D')
	checkType('apply_focal_length', 2, focal_length, 'number')

	local x,y,z = point.x, point.y, point.z
	local x_proj, y_proj
	if (focal_length - z) > 0 then
        x_proj = (focal_length * x) / (focal_length - z)
        y_proj = (focal_length * y) / (focal_length - z)
    else
        -- Handle the case when z = -focal_length to avoid division by zero
       x_proj, y_proj = x, y
	end

    return Point2D(x_proj, y_proj)
end

function p.ModelSphere(geometry, depiction)
	checkType( 'ModelSphere', 1, geometry, 'Sphere' )
	checkType( 'ModelSphere', 2, depiction, 'Depiction' )
	return {
		_TYPE = 'ModelSphere',
		geometry = geometry,
		depiction = depiction
	}
end

function p.ModelCylinder(geometry, depiction)
	checkType( 'ModelCylinder', 1, geometry, 'Cylinder' )
	checkType( 'ModelCylinder', 2, depiction, 'Depiction' )
	return {
		_TYPE = 'ModelCylinder',
		geometry = geometry,
		depiction = depiction
	}
end

function p.ModelWire(geometry, color, width, opacity)
	checkType( 'ModelWire', 1, geometry, 'Line3D' )
	checkType( 'ModelWire', 2, color, 'Color' )
	checkType( 'ModelWire', 3, width, 'number' )
	checkType( 'ModelWire', 4, opacity, 'number' )
	return {
		_TYPE = 'ModelWire',
		geometry = geometry,
		color = color,
		width = width,
		opacity = opacity
	}
end

-- ==============
-- Create visible 2D polygon from node geometry
-- ==============

-- Get the vertices of the polygon that represents the visible part of the node
function p.get_node_polygon_vertices( this, others, resolution, focal_length )
	-- Seems like this should be a method of Model base class, but original doesn't do that, so following that.
	checkType( 'get_node_polygon_verticies', 2, others, 'table' )
	checkType( 'get_node_polygon_verticies', 3, resolution, 'number' )

	local points
	if this._TYPE == 'ModelSphere' then
		points = get_points_on_surface_sphere( this.geometry, resolution, resolution, true )
	elseif this._TYPE == 'ModelCylinder' then
		points = get_points_on_surface_cylinder(this.geometry, math.floor(resolution / 2.0))
	else
		-- If node is not a sphere or cylinder (i.e., unsupported geometries), return empty list.
		return {}
	end

	local visible_points = {}
	for i, point in ipairs(points) do
		local loopBreak = false
	    for j, node in ipairs(others) do
            if node._TYPE == 'ModelSphere' and point_is_inside_sphere(node.geometry, point) then
				loopBreak = true
                break
			elseif node._TYPE == 'ModelCylinder' and point_is_inside_cylinder(node.geometry, point) then
				loopBreak = true
                break
			end
        end
		if not loopBreak then
            local x, y, z = point.x, point.y, point.z

			local am_point
            if focal_length ~= nil then
                am_point = p.apply_focal_length(Point3D(x, y, z), focal_length)
            else
                am_point = Point2D(x, y)
			end
            visible_points[#visible_points+1] = am_point
		end
	end

    -- If no visible points, return empty list.
    if #visible_points == 0 then
        return {}
	end

    -- Calculate convex hull of visible points.
    local inds = calculate_convex_hull(visible_points)
	local verts = {}
	for i,ind in ipairs( inds ) do
		verts[#verts+1] = visible_points[ind]
	end

    return verts
end

-- =============
-- Draw scene
-- =============

-- Filter, rotate, and scale nodes based on what to include in the scene.
function p.prepare_nodes_for_intersecting( nodes_to_sort, include_spheres, include_cylinders, include_wires, rotation_over_x_axis, rotation_over_y_axis, rotation_over_z_axis, scale )
	rotation_over_x_axis = rotation_over_x_axis == nil and 0 or rotation_over_x_axis
	rotation_over_y_axis = rotation_over_y_axis == nil and 0 or rotation_over_y_axis
	rotation_over_z_axis = rotation_over_z_axis == nil and 0 or rotation_over_z_axis
	checkType( 'prepare_nodes_for_intersecting', 1, nodes_to_sort, 'table' )
	checkType( 'prepare_nodes_for_intersecting', 5, rotation_over_x_axis, 'number' )
	checkType( 'prepare_nodes_for_intersecting', 6, rotation_over_y_axis, 'number' )
	checkType( 'prepare_nodes_for_intersecting', 7, rotation_over_z_axis, 'number' )

	local nodes = {}
	for _, node in ipairs( nodes_to_sort ) do
        if node._TYPE == 'ModelSphere' and include_spheres then
            node.geometry.center = node.geometry.center:rotate(
                rotation_over_x_axis, rotation_over_y_axis, rotation_over_z_axis
            )

            if scale ~= nil then
                node.geometry.radius = node.geometry.radius * scale 
                node.geometry.center = Point3D(
                    node.geometry.center.x * scale,
                    node.geometry.center.y * scale,
                    node.geometry.center.z * scale
                )
            
                if node.depiction.name == 'Cartoon' then
                    node.depiction.outline_width = node.depiction.outline_width * scale
				end
			end

            nodes[#nodes+1] = node

        elseif node._TYPE == 'ModelCylinder' and include_cylinders then
            node.geometry.start = node.geometry.start:rotate(
                rotation_over_x_axis, rotation_over_y_axis, rotation_over_z_axis
            )
            node.geometry.endp = node.geometry.endp:rotate(
                rotation_over_x_axis, rotation_over_y_axis, rotation_over_z_axis
            )

            if scale ~= nil then
                node.geometry.radius = node.geometry.radius * scale
                node.geometry.start = Point3D(
                    node.geometry.start.x * scale,
                    node.geometry.start.y * scale,
                    node.geometry.start.z * scale
                )
                node.geometry.endp = Point3D(
                    node.geometry.endp.x * scale,
                    node.geometry.endp.y * scale,
                    node.geometry.endp.z * scale
                )

                if node.depiction.name == 'Cartoon' then
                    node.depiction.outline_width = node.depiction.outline_width * scale
				end
			end

            nodes[#nodes+1] = node

        elseif node._TYPE == 'ModelWire' and include_wires then
            node.geometry.start = node.geometry.start:rotate(
                rotation_over_x_axis, rotation_over_y_axis, rotation_over_z_axis
            )
            node.geometry.endp = node.geometry.endp:rotate(
                rotation_over_x_axis, rotation_over_y_axis, rotation_over_z_axis
            )

            if scale ~= nil then
                node.geometry.start = Point3D(
                    node.geometry.start.x * scale,
                    node.geometry.start.y * scale,
                    node.geometry.start.z * scale
                )
                node.geometry.endp = Point3D(
                    node.geometry.endp.x * scale,
                    node.geometry.endp.y * scale,
                    node.geometry.endp.z * scale
                )

                node.width = node.width * scale
			end

            nodes[#nodes+1] = node
		end
	end
	return nodes
end

-- Create a fill for a node.
function p.create_fill( node, reference )
	checkType( 'create_fill', 1, node, 'table' )
	checkType( 'create_fill', 2, reference, 'string' )

	local fill = nil
	if node._TYPE == 'ModelWire' then
		local stroke_color = node.color
		local stroke_width = node.width
		local opacity = node.opacity
		local style = Wire( stroke_color, stroke_width, opacity )
		fill = Fill(reference, style)
	elseif node.depiction.name == 'Cartoon' then
        local fill_color = node.depiction.fill_color
        local stroke_color = node.depiction.outline_color
        local stroke_width = node.depiction.outline_width
        local opacity = node.depiction.opacity
        local style = Solid(fill_color, stroke_color, stroke_width, opacity)
        fill = Fill(reference, style)

    -- Glossy style is different for spheres and cylinders.
    elseif (
        node.depiction.name == 'Glossy'
        and node._TYPE == 'ModelSphere'
        and node.geometry._TYPE == 'Sphere'
    ) then
        if node.geometry._TYPE ~= 'Sphere' then
            error("Node geometry of ModelSphere must be a sphere.")
		end

        local x, y = node.geometry.center.x, node.geometry.center.y
        local fill_color = node.depiction.fill_color
        local center = Point2D(x, y)
        local radius = node.geometry.radius
        local opacity = node.depiction.opacity
        local style = RadialGradient(fill_color, center, radius, opacity)
        fill = Fill(reference, style)
    elseif node.depiction.name == 'Glossy' and node._TYPE == 'ModelCylinder' then
        if node.geometry._TYPE ~= 'Cylinder' then
            error("Node geometry of ModelCylinder must be a cylinder.")
		end

        local start_x, start_y = node.geometry.start.x, node.geometry.start.y
        local end_x, end_y = node.geometry.endp.x, node.geometry.endp.y
        local fill_color = node.depiction.fill_color
        local start_center = Point2D(start_x, start_y)
        local end_center = Point2D(end_x, end_y)
        local radius = node.geometry.radius
        local opacity = node.depiction.opacity
        local style = LinearGradient(fill_color, start_center, end_center, opacity)
        fill = Fill(reference, style)
	end

    return fill
end

-- Calculate which of the previous nodes intersect with the current node.
function p.calculate_intersecting_nodes(
	node,
	other_nodes,
	calculate_sphere_sphere_intersections,
	calculate_sphere_cylinder_intersections,
	calculate_cylinder_sphere_intersections,
	calculate_cylinder_cylinder_intersections,
	filter_nodes_for_intersecting
)
	checkType( 'calculate_intersecting_nodes', 1, node, 'table' )
	checkType( 'calculate_intersecting_nodes', 2, other_nodes, 'table' )

	local previous_nodes = {}
    -- Wireframe is drawn as a line and has no intersections with other nodes.
    if node._TYPE ~= 'ModelWire' then
        for _, prev_node in ipairs(other_nodes) do

            if not filter_nodes_for_intersecting then
                previous_nodes[#previous_nodes+1] = prev_node
            elseif (
                node._TYPE == 'ModelSphere'
                and prev_node._TYPE == 'ModelSphere'
                and calculate_sphere_sphere_intersections
            ) then
                if sphere_intersects_with_sphere(node.geometry, prev_node.geometry) then
                    previous_nodes[#previous_nodes+1] = prev_node
				end
            elseif (
                node._TYPE == 'ModelSphere' and prev_node._TYPE == 'ModelCylinder'
            ) and calculate_sphere_cylinder_intersections then
                if sphere_intersects_with_cylinder(node.geometry, prev_node.geometry) then
                    previous_nodes[#previous_nodes+1] = prev_node
				end
            elseif (
                node._TYPE == 'ModelCylinder' and prev_node._TYPE == 'ModelSphere'
            ) and calculate_cylinder_sphere_intersections then
                if sphere_intersects_with_cylinder(prev_node.geometry, node.geometry) then
                    previous_nodes[#previous_nodes+1] = prev_node
				end
            elseif (
                node._TYPE == 'ModelCylinder'
                and prev_node._TYPE == 'ModelCylinder'
                and calculate_cylinder_cylinder_intersections
            ) then
                if cylinder_intersects_with_cylinder(node.geometry, prev_node.geometry) then
                    previous_nodes[#previous_nodes+1] = prev_node
				end
			end
		end
	end

    return previous_nodes
end

local function first_n(table, n)
	local res = {}
	for i,v in ipairs(table) do
		if i >= n then
			break
		end
		res[#res+1] = v
	end
	return res
end

function p.Scene( nodes )
	nodes = nodes == nil and {} or nodes
	checkType( 'Scene', 1, nodes, 'table' )
	local obj = {
		_TYPE = 'Scene',
		nodes = nodes
	}

	function obj:add_node( node )
		checkType( 'add_node', 1, self, 'Scene' )
		self.nodes[#self.nodes+1] = node
	end

	function obj:calculate_view_box( points, margin )
		checkType( 'Scene:calculate_view_box', 1, self, 'Scene' )
		checkType( 'Scene:calculate_view_box', 2, points, 'table' )
		checkType( 'Scene:calculate_view_box', 3, margin, 'number' )

        if #points == 0 then
			-- original is missing return, presumably a bug.
            return ViewBox(0, 0, 0, 0)
		end

        local min_x, min_y, max_x, max_y = 1/0, 1/0, -1/0, -1/0
        for _, point in ipairs(points) do
            min_x = math.min(min_x, point.x)
            min_y = math.min(min_y, point.y)
            max_x = math.max(max_x, point.x)
            max_y = math.max(max_y, point.y)
		end

        min_x = min_x - margin
        min_y = min_y - margin
        max_x = max_x + margin
        max_y = max_y + margin

        local width = max_x - min_x
        local height = max_y - min_y
        return ViewBox(min_x, min_y, width, height)
	end

	-- Draw the scene.
	function obj:draw(
		resolution,
		window, -- window might not work due to how we make SVGs. Instead set img attribute on svg
		view_box,
		rotation_over_x_axis,
		rotation_over_y_axis,
		rotation_over_z_axis,
		include_spheres,
		include_cylinders,
		include_wires,
		calculate_sphere_sphere_intersections,
		calculate_sphere_cylinder_intersections,
		calculate_cylinder_sphere_intersections,
		calculate_cylinder_cylinder_intersections,
		filter_nodes_for_intersecting,
		scale,
		focal_length
	)
		checkType( 'Scene:draw', 1, self, 'Scene' )
		checkType( 'Scene:draw', 2, resolution, 'number' )

		rotation_over_x_axis = rotation_over_x_axis == nil and 0 or rotation_over_x_axis
		rotation_over_y_axis = rotation_over_y_axis == nil and 0 or rotation_over_y_axis
		rotation_over_z_axis = rotation_over_z_axis == nil and 0 or rotation_over_z_axis
		include_spheres = include_spheres == nil and true or include_spheres
		include_cylinders = include_cylinders == nil and true or include_cylinders
		include_wires = include_wires == nil and true or include_wires
		calculate_sphere_sphere_intersections = calculate_sphere_sphere_intersections == nil and true or calculate_sphere_sphere_intersections
		calculate_sphere_cylinder_intersections = calculate_sphere_cylinder_intersections == nil and true or calculate_sphere_cylinder_intersections
		calculate_cylinder_sphere_intersections = calculate_cylinder_sphere_intersections == nil and true or calculate_cylinder_sphere_intersections
		calculate_cylinder_cylinder_intersections = calculate_cylinder_cylinder_intersections == nil and true or calculate_cylinder_cylinder_intersections
		scale = scale == nil and 1.0 or scale
		checkType( 'Scene:draw', 16, scale, 'number' )

	    -- Filter geometries.
        local nodes = p.prepare_nodes_for_intersecting(
            self.nodes,
            include_spheres,
            include_cylinders,
            include_wires,
            rotation_over_x_axis,
            rotation_over_y_axis,
            rotation_over_z_axis,
            scale
        )
        -- Get sorting values for nodes. We sort on z-coordinate as we always look at the
        -- scene from the z-axis, towards the origin.
		-- We also use node number to make it a stable sort to emulate the ordering of the python project.
        local sorting_values = {}
        for i, node in ipairs(nodes) do
        	local midpoint_z
            if node._TYPE == 'ModelSphere' then
                sorting_values[#sorting_values+1] = { node.geometry.center.z, i, node }
            elseif node._TYPE == 'ModelCylinder' then
                local cylinder_start = node.geometry.start
                local cylinder_end = node.geometry.endp
                midpoint_z = (cylinder_start.z + cylinder_end.z) / 2
                sorting_values[#sorting_values+1] = { midpoint_z, i, node }
            elseif node._TYPE == 'ModelWire' then
                local wire_start = node.geometry.start
                local wire_end = node.geometry.endp
                midpoint_z = (wire_start.z + wire_end.z) / 2
                sorting_values[#sorting_values+1] = { midpoint_z, i, node }
			end
		end


        -- Sort nodes by sorting values.
		table.sort( sorting_values, function( a, b )
			if a[1] == b[1] then
				return a[2] < b[2]
			end
			return a[1] < b[1]
		end )
		local nodes = {}
		for _,v in ipairs( sorting_values ) do
			nodes[#nodes+1] = v[3]
		end

        -- Keep track of reference points for determining viewbox later on.
        local ref_points = {}

        -- Calculate 2D shape and fill for each node.
        local objects = {}
        local fills = {}
        -- Loop over nodes and create shapes and fills to populate the SVG.
        for i, node in ipairs(nodes) do

            -- Create reference tag for node to connect shape to style.
			-- subtract 1 from i to make it match the python version for easier testing.
            local reference = "node-" .. (i-1)

            -- Calculate which of the previously drawn nodes intersect with the current node.
            local previous_nodes = p.calculate_intersecting_nodes(
                node,
                first_n(nodes, i),
                calculate_sphere_sphere_intersections,
                calculate_sphere_cylinder_intersections,
                calculate_cylinder_sphere_intersections,
                calculate_cylinder_cylinder_intersections,
                filter_nodes_for_intersecting
            )

            -- Calculate line for wire.
            if node._TYPE == 'ModelWire' then
				local start, endp
                if focal_length  ~= nil then
                    start = p.apply_focal_length(node.geometry.start, focal_length)
                    endp = p.apply_focal_length(node.geometry.endp, focal_length)
                else
                    start = Point2D(node.geometry.start.x, node.geometry.start.y)
                    endp = Point2D(node.geometry.endp.x, node.geometry.endp.y)
                end

                local line = Line2D(reference, start, endp)

                if view_box == nil then
                    ref_points[#ref_points+1] = start
                    ref_points[#ref_points+1] = endp
				end
                objects[#objects+1] = line

            -- Otherwise, calculate polygon for visible part of node.
            else
                local points = p.get_node_polygon_vertices(node, previous_nodes, resolution, focal_length)
                local polygon = Polygon2D(reference, points)

                if view_box == nil then
					for _,pt in ipairs( points ) do
						ref_points[#ref_points+1] = pt
                    end
				end

                objects[#objects+1] = polygon
			end
            -- Create style.
            local fill = p.create_fill(node, reference)
            if fill ~= nil then
                fills[#fills+1] = fill
			end

            -- padding = len(str(len(nodes)))
            -- logger.info(f" Drawing node {i + 1:>{padding}} of {len(nodes)}")
		end

        -- Calculate view box.
        if view_box == nil then
            view_box = self:calculate_view_box(ref_points, 5.0)
        end

		-- view_box, window, background_color, fills, objects
        local svg = Svg(view_box, window, nil, fills, objects)
        return svg
	end

	return obj
end


return p

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.