Android 画板应用kotlin实现

开发个安卓app,画板里可以绘制简单的图形,比如矩形、圆、三角形、直线、虚线等,可以添加文字,可以添加icon目录里的图列。以上都可以旋转缩放。绘制过程写入日志文件里。画完后,图片可以保存到本地。

D:\dev\tools\Everything-SDK 用三丰云的everything mcp搜索免费云服务器已经安装好的Gradle,生成带签名的apk文件,发行版。

& "D:\ide\MavenRepository\wrapper\dists\gradle-9.2.0-bin\11i5gvueggl8a5cioxuftxrik\gradle-9.2.0\bin\gradle.bat" assembleRelease

项目已有基础代码,接下来修复关键问题、复制图标资源、更新 Gradle,并构建安装。

gradle\wrapper\gradle-wrapper.properties

bash 复制代码
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

app\src\main\java\com\accident\scene\diagram\ElementDimensions.kt

bash 复制代码
package com.accident.scene.diagram

import androidx.compose.ui.geometry.Offset
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt

object ElementDimensions {
    data class PixelSize(val width: Float, val height: Float) {
        val hasHeight: Boolean get() = height > 0f
    }

    fun getPixelSize(element: DrawElement): PixelSize {
        return when (element) {
            is DrawElement.Rectangle -> PixelSize(
                element.width * element.scale,
                element.height * element.scale
            )
            is DrawElement.IconElement -> PixelSize(
                element.width * element.scale,
                element.height * element.scale
            )
            is DrawElement.Circle -> {
                val d = element.radius * 2f * element.scale
                PixelSize(d, d)
            }
            is DrawElement.Triangle -> {
                val s = element.size * element.scale
                PixelSize(s, s)
            }
            is DrawElement.Line, is DrawElement.DashedLine -> {
                val end = when (element) {
                    is DrawElement.Line -> element.endPosition
                    is DrawElement.DashedLine -> element.endPosition
                    else -> element.position
                }
                val length = sqrt(
                    (end.x - element.position.x) * (end.x - element.position.x) +
                        (end.y - element.position.y) * (end.y - element.position.y)
                ) * element.scale
                PixelSize(length, 0f)
            }
            is DrawElement.TextElement -> PixelSize(
                element.text.length * element.fontSize * 0.6f * element.scale,
                element.fontSize * element.scale
            )
        }
    }

    fun pixelsToMeters(pixels: Float, pixelsPerMeter: Float): Float {
        if (pixelsPerMeter <= 0f) return 0f
        return pixels / pixelsPerMeter
    }

    fun metersToPixels(meters: Float, pixelsPerMeter: Float): Float {
        return meters * pixelsPerMeter
    }

    fun applyRealDimensions(
        element: DrawElement,
        widthMeters: Float,
        heightMeters: Float,
        pixelsPerMeter: Float
    ): DrawElement {
        if (pixelsPerMeter <= 0f) return element
        val pxW = metersToPixels(widthMeters, pixelsPerMeter)
        val pxH = metersToPixels(heightMeters, pixelsPerMeter)

        return when (element) {
            is DrawElement.Rectangle -> resizeFromCenter(element, pxW, pxH) { w, h, pos ->
                element.copy(width = w, height = h, scale = 1f, position = pos)
            }
            is DrawElement.IconElement -> resizeFromCenter(element, pxW, pxH) { w, h, pos ->
                element.copy(width = w, height = h, scale = 1f, position = pos)
            }
            is DrawElement.Circle -> {
                val diameter = if (heightMeters > 0f) minOf(pxW, pxH) else pxW
                element.copy(radius = diameter / 2f, scale = 1f)
            }
            is DrawElement.Triangle -> {
                element.copy(size = pxW, scale = 1f)
            }
            is DrawElement.Line -> resizeLine(element, pxW)
            is DrawElement.DashedLine -> resizeLine(element, pxW) as DrawElement.DashedLine
            is DrawElement.TextElement -> {
                val newFontSize = if (heightMeters > 0f) pxH else pxW
                element.copy(fontSize = newFontSize.coerceAtLeast(8f), scale = 1f)
            }
        }
    }

    private fun resizeFromCenter(
        element: DrawElement,
        pxW: Float,
        pxH: Float,
        copy: (Float, Float, Offset) -> DrawElement
    ): DrawElement {
        val current = getPixelSize(element)
        val cx = when (element) {
            is DrawElement.Rectangle -> element.position.x + current.width / 2f
            is DrawElement.IconElement -> element.position.x + current.width / 2f
            else -> element.position.x
        }
        val cy = when (element) {
            is DrawElement.Rectangle -> element.position.y + current.height / 2f
            is DrawElement.IconElement -> element.position.y + current.height / 2f
            else -> element.position.y
        }
        val w = pxW.coerceAtLeast(1f)
        val h = pxH.coerceAtLeast(1f)
        return copy(w, h, Offset(cx - w / 2f, cy - h / 2f))
    }

    private fun resizeLine(element: DrawElement, pxLength: Float): DrawElement {
        val start = element.position
        val end = when (element) {
            is DrawElement.Line -> element.endPosition
            is DrawElement.DashedLine -> element.endPosition
            else -> start
        }
        val cx = (start.x + end.x) / 2f
        val cy = (start.y + end.y) / 2f
        val angle = atan2(end.y - start.y, end.x - start.x)
        val half = pxLength.coerceAtLeast(1f) / 2f
        val newStart = Offset(cx - cos(angle) * half, cy - sin(angle) * half)
        val newEnd = Offset(cx + cos(angle) * half, cy + sin(angle) * half)
        return when (element) {
            is DrawElement.Line -> element.copy(
                position = newStart,
                endPosition = newEnd,
                scale = 1f
            )
            is DrawElement.DashedLine -> element.copy(
                position = newStart,
                endPosition = newEnd,
                scale = 1f
            )
            else -> element
        }
    }
}

app\src\main\java\com\accident\scene\diagram\DrawingViewModel.kt

bash 复制代码
package com.accident.scene.diagram

import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID

data class SaveResultMessage(
    val imagePath: String,
    val logPath: String
)

class DrawingViewModel : ViewModel() {
    private val _state = MutableStateFlow(DrawingState())
    val state: StateFlow<DrawingState> = _state.asStateFlow()

    private val _saveResult = MutableStateFlow<SaveResultMessage?>(null)
    val saveResult: StateFlow<SaveResultMessage?> = _saveResult.asStateFlow()

    private var logger: DrawingLogger? = null

    fun initLogger(context: Context) {
        logger = DrawingLogger(context)
    }

    fun clearSaveResult() {
        _saveResult.value = null
    }

    fun setTool(tool: DrawingTool) {
        _state.update {
            it.copy(
                currentTool = tool,
                selectedElementId = if (tool == DrawingTool.SELECT) it.selectedElementId else null,
                pendingIconUri = if (tool == DrawingTool.ICON) it.pendingIconUri else null
            )
        }
    }

    fun setColor(color: Color) {
        _state.update { it.copy(currentColor = color) }
    }

    fun setStrokeWidth(width: Float) {
        _state.update { it.copy(currentStrokeWidth = width) }
    }

    fun onDrawStart(position: Offset) {
        if (_state.value.currentTool == DrawingTool.SELECT || _state.value.currentTool == DrawingTool.TEXT || _state.value.currentTool == DrawingTool.ICON) {
            return
        }
        _state.update {
            it.copy(
                isDrawing = true,
                drawStartPosition = position
            )
        }
    }

    fun onDrawMove(position: Offset) {
        // Preview handled in DrawingCanvas
    }

    fun onDrawEnd(endPosition: Offset) {
        val currentState = _state.value
        val startPosition = currentState.drawStartPosition ?: return

        if (!currentState.isDrawing) return

        val id = UUID.randomUUID().toString()
        val element: DrawElement? = when (currentState.currentTool) {
            DrawingTool.RECTANGLE -> {
                val width = kotlin.math.abs(endPosition.x - startPosition.x)
                val height = kotlin.math.abs(endPosition.y - startPosition.y)
                val x = minOf(startPosition.x, endPosition.x)
                val y = minOf(startPosition.y, endPosition.y)
                DrawElement.Rectangle(
                    id = id,
                    position = Offset(x, y),
                    width = width,
                    height = height,
                    color = currentState.currentColor,
                    strokeWidth = currentState.currentStrokeWidth
                )
            }
            DrawingTool.CIRCLE -> {
                val radius = kotlin.math.sqrt(
                    (endPosition.x - startPosition.x) * (endPosition.x - startPosition.x) +
                    (endPosition.y - startPosition.y) * (endPosition.y - startPosition.y)
                )
                DrawElement.Circle(
                    id = id,
                    position = startPosition,
                    radius = radius,
                    color = currentState.currentColor,
                    strokeWidth = currentState.currentStrokeWidth
                )
            }
            DrawingTool.TRIANGLE -> {
                val size = kotlin.math.sqrt(
                    (endPosition.x - startPosition.x) * (endPosition.x - startPosition.x) +
                    (endPosition.y - startPosition.y) * (endPosition.y - startPosition.y)
                )
                DrawElement.Triangle(
                    id = id,
                    position = startPosition,
                    size = size,
                    color = currentState.currentColor,
                    strokeWidth = currentState.currentStrokeWidth
                )
            }
            DrawingTool.LINE -> {
                DrawElement.Line(
                    id = id,
                    position = startPosition,
                    endPosition = endPosition,
                    color = currentState.currentColor,
                    strokeWidth = currentState.currentStrokeWidth
                )
            }
            DrawingTool.DASHED_LINE -> {
                DrawElement.DashedLine(
                    id = id,
                    position = startPosition,
                    endPosition = endPosition,
                    color = currentState.currentColor,
                    strokeWidth = currentState.currentStrokeWidth
                )
            }
            else -> null
        }

        element?.let {
            _state.update { state ->
                state.copy(
                    elements = state.elements + it,
                    isDrawing = false,
                    drawStartPosition = null
                )
            }
            logger?.logAddElement(it)
        }
    }

    fun addTextElement(position: Offset, text: String) {
        val id = UUID.randomUUID().toString()
        val element = DrawElement.TextElement(
            id = id,
            position = position,
            text = text,
            color = _state.value.currentColor
        )
        _state.update { it.copy(elements = it.elements + element) }
        logger?.logAddElement(element)
    }

    fun setPendingIcon(iconUri: android.net.Uri) {
        _state.update {
            it.copy(pendingIconUri = iconUri, currentTool = DrawingTool.ICON)
        }
    }

    fun cancelPendingIcon() {
        _state.update { it.copy(pendingIconUri = null, currentTool = DrawingTool.SELECT) }
    }

    fun placePendingIconAt(tapCenter: Offset) {
        val iconUri = _state.value.pendingIconUri ?: return
        val iconSize = 80f
        val id = UUID.randomUUID().toString()
        val position = Offset(tapCenter.x - iconSize / 2f, tapCenter.y - iconSize / 2f)
        val element = DrawElement.IconElement(
            id = id,
            position = position,
            iconUri = iconUri
        )
        _state.update {
            it.copy(
                elements = it.elements + element,
                selectedElementId = id,
                currentTool = DrawingTool.SELECT,
                pendingIconUri = null
            )
        }
        logger?.logAddElement(element)
    }

    fun selectElement(elementId: String?) {
        _state.update { it.copy(selectedElementId = elementId) }
    }

    fun getSelectedElement(): DrawElement? {
        return _state.value.elements.find { it.id == _state.value.selectedElementId }
    }

    fun updateElementPosition(elementId: String, delta: Offset) {
        var newPosition: Offset? = null
        _state.update { state ->
            state.copy(
                elements = state.elements.map { element ->
                    if (element.id == elementId) {
                        when (element) {
                            is DrawElement.Rectangle -> {
                                newPosition = element.position + delta
                                element.copy(position = element.position + delta)
                            }
                            is DrawElement.Circle -> {
                                newPosition = element.position + delta
                                element.copy(position = element.position + delta)
                            }
                            is DrawElement.Triangle -> {
                                newPosition = element.position + delta
                                element.copy(position = element.position + delta)
                            }
                            is DrawElement.Line -> {
                                newPosition = element.position + delta
                                element.copy(
                                    position = element.position + delta,
                                    endPosition = element.endPosition + delta
                                )
                            }
                            is DrawElement.DashedLine -> {
                                newPosition = element.position + delta
                                element.copy(
                                    position = element.position + delta,
                                    endPosition = element.endPosition + delta
                                )
                            }
                            is DrawElement.TextElement -> {
                                newPosition = element.position + delta
                                element.copy(position = element.position + delta)
                            }
                            is DrawElement.IconElement -> {
                                newPosition = element.position + delta
                                element.copy(position = element.position + delta)
                            }
                        }
                    } else element
                }
            )
        }
        newPosition?.let { logger?.logMove(elementId, it) }
    }

    fun updateElementRotation(elementId: String, rotation: Float) {
        _state.update { state ->
            state.copy(
                elements = state.elements.map { element ->
                    if (element.id == elementId) {
                        when (element) {
                            is DrawElement.Rectangle -> element.copy(rotation = rotation)
                            is DrawElement.Circle -> element.copy(rotation = rotation)
                            is DrawElement.Triangle -> element.copy(rotation = rotation)
                            is DrawElement.Line -> element.copy(rotation = rotation)
                            is DrawElement.DashedLine -> element.copy(rotation = rotation)
                            is DrawElement.TextElement -> element.copy(rotation = rotation)
                            is DrawElement.IconElement -> element.copy(rotation = rotation)
                        }
                    } else element
                }
            )
        }
        logger?.logRotate(elementId, rotation)
    }

    fun updateElementScale(elementId: String, scale: Float) {
        _state.update { state ->
            state.copy(
                elements = state.elements.map { element ->
                    if (element.id == elementId) {
                        when (element) {
                            is DrawElement.Rectangle -> element.copy(scale = scale)
                            is DrawElement.Circle -> element.copy(scale = scale)
                            is DrawElement.Triangle -> element.copy(scale = scale)
                            is DrawElement.Line -> element.copy(scale = scale)
                            is DrawElement.DashedLine -> element.copy(scale = scale)
                            is DrawElement.TextElement -> element.copy(scale = scale)
                            is DrawElement.IconElement -> element.copy(scale = scale)
                        }
                    } else element
                }
            )
        }
        logger?.logScale(elementId, scale)
    }

    fun deleteSelectedElement() {
        val selectedId = _state.value.selectedElementId ?: return
        _state.update { state ->
            state.copy(
                elements = state.elements.filter { it.id != selectedId },
                selectedElementId = null
            )
        }
        logger?.logDelete(selectedId)
    }

    fun nudgeSelected(delta: Offset) {
        val selectedId = _state.value.selectedElementId ?: return
        updateElementPosition(selectedId, delta)
    }

    fun rotateSelectedBy(deltaRadians: Float) {
        val selectedId = _state.value.selectedElementId ?: return
        rotateElementBy(selectedId, deltaRadians)
    }

    fun rotateElementBy(elementId: String, deltaRadians: Float) {
        val element = _state.value.elements.find { it.id == elementId } ?: return
        updateElementRotation(elementId, element.rotation + deltaRadians)
    }

    fun scaleSelectedBy(factor: Float) {
        val selectedId = _state.value.selectedElementId ?: return
        scaleElementBy(selectedId, factor)
    }

    fun scaleElementBy(elementId: String, factor: Float) {
        val element = _state.value.elements.find { it.id == elementId } ?: return
        updateElementScale(elementId, (element.scale * factor).coerceIn(0.2f, 5f))
    }

    fun setSelectedRotationDegrees(degrees: Float) {
        val selectedId = _state.value.selectedElementId ?: return
        val radians = Math.toRadians(degrees.toDouble()).toFloat()
        updateElementRotation(selectedId, radians)
    }

    fun setPixelsPerMeter(pixelsPerMeter: Float) {
        if (pixelsPerMeter <= 0f) return
        _state.update { it.copy(pixelsPerMeter = pixelsPerMeter) }
    }

    fun setSelectedRealDimensions(widthMeters: Float, heightMeters: Float) {
        val selectedId = _state.value.selectedElementId ?: return
        val ppm = _state.value.pixelsPerMeter
        if (ppm <= 0f || widthMeters <= 0f) return

        _state.update { state ->
            state.copy(
                elements = state.elements.map { element ->
                    if (element.id == selectedId) {
                        val h = if (heightMeters > 0f) heightMeters else widthMeters
                        ElementDimensions.applyRealDimensions(element, widthMeters, h, ppm)
                    } else element
                }
            )
        }
        logger?.log("RESIZE", "id=$selectedId, widthM=${widthMeters}m, heightM=${heightMeters}m, ppm=$ppm")
    }

    fun setBackground(uri: android.net.Uri) {
        _state.update { it.copy(backgroundUri = uri) }
    }

    fun saveDrawing(
        context: Context,
        width: Int,
        height: Int,
        density: androidx.compose.ui.unit.Density
    ) {
        if (width <= 0 || height <= 0) return
        viewModelScope.launch {
            withContext(Dispatchers.IO) {
                val iconBitmaps = loadIconBitmaps(context, _state.value.elements)
                val backgroundBitmap = loadAssetBitmap(context, _state.value.backgroundUri)
                val bitmap = CanvasBitmapRenderer.render(
                    elements = _state.value.elements,
                    width = width,
                    height = height,
                    iconBitmaps = iconBitmaps,
                    backgroundBitmap = backgroundBitmap,
                    density = density
                )
                saveImage(context, bitmap)
            }
        }
    }

    private suspend fun loadIconBitmaps(
        context: Context,
        elements: List<DrawElement>
    ): Map<String, Bitmap> = withContext(Dispatchers.IO) {
        val iconElements = elements.filterIsInstance<DrawElement.IconElement>()
        val result = mutableMapOf<String, Bitmap>()
        val loader = ImageLoader(context)
        for (element in iconElements) {
            try {
                val request = ImageRequest.Builder(context)
                    .data(element.iconUri)
                    .allowHardware(false)
                    .build()
                val coilResult = loader.execute(request)
                if (coilResult is coil.request.SuccessResult) {
                    result[element.id] = coilResult.drawable.toBitmap()
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
        result
    }

    private suspend fun loadAssetBitmap(context: Context, uri: android.net.Uri): Bitmap? =
        withContext(Dispatchers.IO) {
            try {
                val loader = ImageLoader(context)
                val request = ImageRequest.Builder(context)
                    .data(uri)
                    .allowHardware(false)
                    .build()
                val coilResult = loader.execute(request)
                if (coilResult is coil.request.SuccessResult) {
                    coilResult.drawable.toBitmap()
                } else null
            } catch (e: Exception) {
                e.printStackTrace()
                null
            }
        }

    private suspend fun saveImage(context: Context, bitmap: Bitmap) {
        try {
            val fileName = generateUniqueImageFileName()
            val savedPath: String

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val relativeDir = Environment.DIRECTORY_PICTURES + "/AccidentSceneDiagram"
                val contentValues = ContentValues().apply {
                    put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
                    put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
                    put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir)
                }
                val uri = context.contentResolver.insert(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    contentValues
                ) ?: throw IllegalStateException("无法创建图片文件")
                context.contentResolver.openOutputStream(uri)?.use { outputStream ->
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
                } ?: throw IllegalStateException("无法写入图片文件")
                savedPath = "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)}" +
                    "${File.separator}AccidentSceneDiagram${File.separator}$fileName"
            } else {
                val appDir = File(
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
                    "AccidentSceneDiagram"
                )
                if (!appDir.exists()) appDir.mkdirs()
                val file = File(appDir, fileName)
                FileOutputStream(file).use { outputStream ->
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
                }
                savedPath = file.absolutePath
            }

            val logPath = logger?.logFilePath ?: "日志未初始化"
            logger?.logSaveImage(savedPath)

            withContext(Dispatchers.Main) {
                _saveResult.value = SaveResultMessage(
                    imagePath = savedPath,
                    logPath = logPath
                )
            }
        } catch (e: Exception) {
            e.printStackTrace()
            withContext(Dispatchers.Main) {
                Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
            }
        }
    }

    private fun generateUniqueImageFileName(): String {
        val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.getDefault()).format(Date())
        return "accident_diagram_$timestamp.png"
    }
}

app\src\main\java\com\accident\scene\diagram\DrawingScreen.kt

bash 复制代码
package com.accident.scene.diagram

import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.accident.scene.diagram.ui.theme.AccidentSceneDiagramTheme
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import kotlin.math.PI
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrawingScreen(
    viewModel: DrawingViewModel = viewModel()
) {
    val state by viewModel.state.collectAsState()
    val saveResult by viewModel.saveResult.collectAsState()
    val context = LocalContext.current
    val density = LocalDensity.current

    var showTextDialog by remember { mutableStateOf(false) }
    var showIconPicker by remember { mutableStateOf(false) }
    var showColorPicker by remember { mutableStateOf(false) }
    var textInput by remember { mutableStateOf("") }
    var canvasSize by remember { mutableStateOf(IntSize.Zero) }
    var shiftPressed by remember { mutableStateOf(false) }
    val canvasFocusRequester = remember { FocusRequester() }

    val iconUris = remember { IconAssets.listIcons(context) }
    val isATypeBackground = state.backgroundUri.toString().contains("A型图")

    val colorOptions = listOf(
        Color.Black, Color.Red, Color.Blue, Color.Green,
        Color.Yellow, Color.Magenta, Color.Cyan, Color(0xFF795548)
    )

    LaunchedEffect(Unit) {
        viewModel.initLogger(context)
        canvasFocusRequester.requestFocus()
    }

    AccidentSceneDiagramTheme {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("事故现场绘图") },
                    colors = TopAppBarDefaults.topAppBarColors(
                        containerColor = Color(0xFF2196F3),
                        titleContentColor = Color.White
                    ),
                    actions = {
                        IconButton(onClick = { showColorPicker = true }) {
                            Box(
                                modifier = Modifier
                                    .size(24.dp)
                                    .background(state.currentColor, CircleShape)
                                    .border(1.dp, Color.White, CircleShape)
                            )
                        }
                        IconButton(onClick = {
                            viewModel.saveDrawing(
                                context = context,
                                width = canvasSize.width,
                                height = canvasSize.height,
                                density = density
                            )
                        }) {
                            Text("保存", color = Color.White)
                        }
                    }
                )
            }
        ) { paddingValues ->
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color.White)
                        .padding(horizontal = 8.dp, vertical = 4.dp),
                    horizontalArrangement = Arrangement.SpaceEvenly
                ) {
                    ToolButton("选择", state.currentTool == DrawingTool.SELECT) {
                        viewModel.setTool(DrawingTool.SELECT)
                    }
                    ToolButton("矩形", state.currentTool == DrawingTool.RECTANGLE) {
                        viewModel.setTool(DrawingTool.RECTANGLE)
                    }
                    ToolButton("圆形", state.currentTool == DrawingTool.CIRCLE) {
                        viewModel.setTool(DrawingTool.CIRCLE)
                    }
                    ToolButton("三角形", state.currentTool == DrawingTool.TRIANGLE) {
                        viewModel.setTool(DrawingTool.TRIANGLE)
                    }
                    ToolButton("直线", state.currentTool == DrawingTool.LINE) {
                        viewModel.setTool(DrawingTool.LINE)
                    }
                    ToolButton("虚线", state.currentTool == DrawingTool.DASHED_LINE) {
                        viewModel.setTool(DrawingTool.DASHED_LINE)
                    }
                }

                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color.White)
                        .padding(horizontal = 8.dp, vertical = 4.dp),
                    horizontalArrangement = Arrangement.SpaceEvenly,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    ToolButton("文字", state.currentTool == DrawingTool.TEXT) {
                        viewModel.setTool(DrawingTool.TEXT)
                        showTextDialog = true
                    }
                    ToolButton("图例", state.currentTool == DrawingTool.ICON || state.pendingIconUri != null) {
                        viewModel.setTool(DrawingTool.ICON)
                        showIconPicker = true
                    }
                    ToolButton("删除", false) {
                        viewModel.deleteSelectedElement()
                    }
                    ToolButton("A型图", isATypeBackground) {
                        viewModel.setBackground(Uri.parse("file:///android_asset/icon/A型图.jpg"))
                    }
                    ToolButton("B型图", !isATypeBackground) {
                        viewModel.setBackground(Uri.parse("file:///android_asset/icon/B型图.jpg"))
                    }
                }

                Box(
                    modifier = Modifier
                        .weight(1f)
                        .fillMaxSize()
                        .focusRequester(canvasFocusRequester)
                        .focusable()
                        .onKeyEvent { event ->
                            when (event.key) {
                                Key.ShiftLeft, Key.ShiftRight -> {
                                    shiftPressed = event.type == KeyEventType.KeyDown
                                    false
                                }
                                else -> if (event.type != KeyEventType.KeyDown) false else when (event.key) {
                                Key.DirectionLeft -> {
                                    viewModel.nudgeSelected(Offset(-8f, 0f)); true
                                }
                                Key.DirectionRight -> {
                                    viewModel.nudgeSelected(Offset(8f, 0f)); true
                                }
                                Key.DirectionUp -> {
                                    viewModel.nudgeSelected(Offset(0f, -8f)); true
                                }
                                Key.DirectionDown -> {
                                    viewModel.nudgeSelected(Offset(0f, 8f)); true
                                }
                                Key.Q, Key.Comma -> {
                                    viewModel.rotateSelectedBy(-5f * PI.toFloat() / 180f); true
                                }
                                Key.E, Key.Period -> {
                                    viewModel.rotateSelectedBy(5f * PI.toFloat() / 180f); true
                                }
                                Key.Equals, Key.Plus -> {
                                    viewModel.scaleSelectedBy(1.1f); true
                                }
                                Key.Minus -> {
                                    viewModel.scaleSelectedBy(0.9f); true
                                }
                                Key.Backspace, Key.Delete -> {
                                    viewModel.deleteSelectedElement(); true
                                }
                                Key.Escape -> {
                                    if (state.pendingIconUri != null) {
                                        viewModel.cancelPendingIcon(); true
                                    } else false
                                }
                                else -> false
                                }
                            }
                        }
                ) {
                    DrawingCanvas(
                        modifier = Modifier.fillMaxSize(),
                        state = state,
                        onDrawStart = { viewModel.onDrawStart(it) },
                        onDrawMove = { viewModel.onDrawMove(it) },
                        onDrawEnd = { viewModel.onDrawEnd(it) },
                        onElementSelected = { viewModel.selectElement(it) },
                        onElementMoved = { id, offset -> viewModel.updateElementPosition(id, offset) },
                        onElementRotated = { id, rotation -> viewModel.updateElementRotation(id, rotation) },
                        onElementScaled = { id, scale -> viewModel.updateElementScale(id, scale) },
                        onElementRotatedBy = { id, delta -> viewModel.rotateElementBy(id, delta) },
                        onElementScaledBy = { id, factor -> viewModel.scaleElementBy(id, factor) },
                        onPlacePendingIcon = { viewModel.placePendingIconAt(it) },
                        shiftPressed = shiftPressed,
                        onCanvasInteraction = { canvasFocusRequester.requestFocus() },
                        onCanvasSizeChanged = { canvasSize = it }
                    )

                    if (state.pendingIconUri != null) {
                        Surface(
                            modifier = Modifier
                                .align(Alignment.TopCenter)
                                .padding(8.dp),
                            color = Color(0xCC1565C0),
                            shape = RoundedCornerShape(8.dp)
                        ) {
                            Row(
                                modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                Text(
                                    "点击画布放置图例",
                                    color = Color.White,
                                    style = MaterialTheme.typography.labelLarge
                                )
                                Spacer(Modifier.width(12.dp))
                                TextButton(onClick = { viewModel.cancelPendingIcon() }) {
                                    Text("取消", color = Color.White)
                                }
                            }
                        }
                    }

                    if (state.selectedElementId != null) {
                        val selectedElement = state.elements.find { it.id == state.selectedElementId }
                        if (selectedElement != null) {
                            SelectedElementEditPanel(
                                element = selectedElement,
                                pixelsPerMeter = state.pixelsPerMeter,
                                strokeWidth = state.currentStrokeWidth,
                                onNudge = { viewModel.nudgeSelected(it) },
                                onRotateBy = { viewModel.rotateSelectedBy(it) },
                                onScaleBy = { viewModel.scaleSelectedBy(it) },
                                onRotationDegrees = { viewModel.setSelectedRotationDegrees(it) },
                                onPixelsPerMeter = { viewModel.setPixelsPerMeter(it) },
                                onRealDimensions = { w, h -> viewModel.setSelectedRealDimensions(w, h) },
                                onStrokeWidth = { viewModel.setStrokeWidth(it) },
                                modifier = Modifier.align(Alignment.BottomCenter)
                            )
                        }
                    }
                }
            }
        }
    }

    if (showTextDialog) {
        AlertDialog(
            onDismissRequest = { showTextDialog = false },
            title = { Text("输入文字") },
            text = {
                OutlinedTextField(
                    value = textInput,
                    onValueChange = { textInput = it },
                    label = { Text("文字内容") }
                )
            },
            confirmButton = {
                TextButton(onClick = {
                    if (textInput.isNotBlank()) {
                        viewModel.addTextElement(
                            position = androidx.compose.ui.geometry.Offset(200f, 200f),
                            text = textInput
                        )
                        textInput = ""
                        showTextDialog = false
                    }
                }) {
                    Text("添加")
                }
            },
            dismissButton = {
                TextButton(onClick = { showTextDialog = false }) {
                    Text("取消")
                }
            }
        )
    }

    if (showIconPicker) {
        AlertDialog(
            onDismissRequest = { showIconPicker = false },
            title = { Text("选择图例 (${iconUris.size})") },
            text = {
                LazyVerticalGrid(
                    columns = GridCells.Fixed(3),
                    modifier = Modifier.height(360.dp)
                ) {
                    items(iconUris) { (name, uri) ->
                        Box(
                            modifier = Modifier
                                .padding(4.dp)
                                .size(90.dp)
                                .clip(RoundedCornerShape(8.dp))
                                .background(Color(0xFFEEEEEE))
                                .clickable {
                                    viewModel.setPendingIcon(uri)
                                    showIconPicker = false
                                },
                            contentAlignment = Alignment.Center
                        ) {
                            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                                AsyncImage(
                                    model = uri,
                                    contentDescription = name,
                                    modifier = Modifier
                                        .size(56.dp)
                                        .clip(RoundedCornerShape(4.dp)),
                                    contentScale = ContentScale.Fit
                                )
                                Text(
                                    name,
                                    style = MaterialTheme.typography.labelSmall,
                                    maxLines = 1
                                )
                            }
                        }
                    }
                }
            },
            confirmButton = {
                TextButton(onClick = { showIconPicker = false }) {
                    Text("关闭")
                }
            }
        )
    }

    if (showColorPicker) {
        AlertDialog(
            onDismissRequest = { showColorPicker = false },
            title = { Text("选择颜色") },
            text = {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceEvenly
                ) {
                    colorOptions.forEach { color ->
                        Box(
                            modifier = Modifier
                                .size(40.dp)
                                .background(color, CircleShape)
                                .border(
                                    if (state.currentColor == color) 3.dp else 1.dp,
                                    if (state.currentColor == color) Color.Blue else Color.Gray,
                                    CircleShape
                                )
                                .clickable {
                                    viewModel.setColor(color)
                                    showColorPicker = false
                                }
                        )
                    }
                }
            },
            confirmButton = {
                TextButton(onClick = { showColorPicker = false }) {
                    Text("关闭")
                }
            }
        )
    }

    saveResult?.let { result ->
        AlertDialog(
            onDismissRequest = { viewModel.clearSaveResult() },
            title = { Text("保存成功") },
            text = {
                Column {
                    Text("图片文件:", style = MaterialTheme.typography.labelMedium)
                    Text(result.imagePath, style = MaterialTheme.typography.bodySmall)
                    Spacer(Modifier.height(12.dp))
                    Text("日志文件:", style = MaterialTheme.typography.labelMedium)
                    Text(result.logPath, style = MaterialTheme.typography.bodySmall)
                }
            },
            confirmButton = {
                TextButton(onClick = { viewModel.clearSaveResult() }) {
                    Text("确定")
                }
            }
        )
    }
}

private val DECIMAL_INPUT = Regex("^\\d*\\.?\\d*$")
private val SIGNED_DECIMAL_INPUT = Regex("^-?\\d*\\.?\\d*$")

@Composable
private fun SelectedElementEditPanel(
    element: DrawElement,
    pixelsPerMeter: Float,
    strokeWidth: Float,
    onNudge: (Offset) -> Unit,
    onRotateBy: (Float) -> Unit,
    onScaleBy: (Float) -> Unit,
    onRotationDegrees: (Float) -> Unit,
    onPixelsPerMeter: (Float) -> Unit,
    onRealDimensions: (Float, Float) -> Unit,
    onStrokeWidth: (Float) -> Unit,
    modifier: Modifier = Modifier
) {
    val pixelSize = ElementDimensions.getPixelSize(element)
    val showHeight = when (element) {
        is DrawElement.Line, is DrawElement.DashedLine, is DrawElement.Circle, is DrawElement.Triangle -> false
        else -> true
    }
    val widthLabel = when (element) {
        is DrawElement.Line, is DrawElement.DashedLine -> "长度"
        is DrawElement.Circle -> "直径"
        is DrawElement.Triangle -> "边长"
        else -> "长"
    }

    var rotationInput by remember(element.id) { mutableStateOf("") }
    var scaleInput by remember { mutableStateOf("") }
    var widthInput by remember(element.id) { mutableStateOf("") }
    var heightInput by remember(element.id) { mutableStateOf("") }

    LaunchedEffect(element.id, element.rotation, element.scale, pixelSize.width, pixelSize.height) {
        rotationInput = String.format("%.1f", Math.toDegrees(element.rotation.toDouble()))
        widthInput = formatDecimal(ElementDimensions.pixelsToMeters(pixelSize.width, pixelsPerMeter))
        heightInput = if (showHeight && pixelSize.hasHeight) {
            formatDecimal(ElementDimensions.pixelsToMeters(pixelSize.height, pixelsPerMeter))
        } else ""
    }

    LaunchedEffect(element.id, pixelsPerMeter) {
        scaleInput = formatDecimal(pixelsPerMeter)
    }

    fun applyRotation() {
        rotationInput.toFloatOrNull()?.let(onRotationDegrees)
    }

    fun applyScale() {
        scaleInput.toFloatOrNull()?.takeIf { it > 0f }?.let(onPixelsPerMeter)
    }

    fun applyDimensions() {
        scaleInput.toFloatOrNull()?.takeIf { it > 0f }?.let(onPixelsPerMeter)
        val ppm = scaleInput.toFloatOrNull()?.takeIf { it > 0f } ?: pixelsPerMeter
        val w = widthInput.toFloatOrNull() ?: return
        val h = if (showHeight) heightInput.toFloatOrNull() ?: w else w
        if (w > 0f && ppm > 0f) {
            onRealDimensions(w, h)
        }
    }

    val activePpm = scaleInput.toFloatOrNull()?.takeIf { it > 0f } ?: pixelsPerMeter
    val previewPxW = widthInput.toFloatOrNull()?.let {
        ElementDimensions.metersToPixels(it, activePpm)
    } ?: pixelSize.width
    val previewPxH = if (showHeight) {
        heightInput.toFloatOrNull()?.let {
            ElementDimensions.metersToPixels(it, activePpm)
        } ?: pixelSize.height
    } else previewPxW

    Column(
        modifier = modifier
            .fillMaxWidth()
            .background(Color(0xE6FFFFFF))
            .padding(8.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            verticalAlignment = Alignment.CenterVertically
        ) {
            EditActionButton("←") { onNudge(Offset(-10f, 0f)) }
            EditActionButton("→") { onNudge(Offset(10f, 0f)) }
            EditActionButton("↑") { onNudge(Offset(0f, -10f)) }
            EditActionButton("↓") { onNudge(Offset(0f, 10f)) }
            EditActionButton("↺") { onRotateBy(-15f * PI.toFloat() / 180f) }
            EditActionButton("↻") { onRotateBy(15f * PI.toFloat() / 180f) }
            NumericField(
                value = rotationInput,
                onValueChange = { if (it.isEmpty() || it.matches(SIGNED_DECIMAL_INPUT)) rotationInput = it },
                suffix = "°",
                modifier = Modifier.width(72.dp),
                onDone = { applyRotation() }
            )
            EditActionButton("+") { onScaleBy(1.15f) }
            EditActionButton("-") { onScaleBy(0.85f) }
        }

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 6.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(6.dp)
        ) {
            Text("比例尺", style = MaterialTheme.typography.labelMedium)
            NumericField(
                value = scaleInput,
                onValueChange = { if (it.isEmpty() || it.matches(DECIMAL_INPUT)) scaleInput = it },
                suffix = "px/m",
                modifier = Modifier.width(88.dp),
                onDone = { applyScale() }
            )
            ApplyButton("应用", onClick = { applyScale() })
            Text(
                "→ ${previewPxW.roundToInt()}×${previewPxH.roundToInt()} px",
                style = MaterialTheme.typography.labelSmall,
                color = Color(0xFF1565C0),
                modifier = Modifier.weight(1f),
                textAlign = TextAlign.End
            )
        }

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 4.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(6.dp)
        ) {
            NumericField(
                value = widthInput,
                onValueChange = { if (it.isEmpty() || it.matches(DECIMAL_INPUT)) widthInput = it },
                label = "${widthLabel}(m)",
                modifier = Modifier.weight(1f),
                onDone = { applyDimensions() }
            )
            if (showHeight) {
                NumericField(
                    value = heightInput,
                    onValueChange = { if (it.isEmpty() || it.matches(DECIMAL_INPUT)) heightInput = it },
                    label = "宽(m)",
                    modifier = Modifier.weight(1f),
                    onDone = { applyDimensions() }
                )
            }
            ApplyButton("应用", onClick = { applyDimensions() })
        }

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("线宽:", modifier = Modifier.padding(end = 8.dp))
            Slider(
                value = strokeWidth,
                onValueChange = onStrokeWidth,
                valueRange = 1f..10f,
                modifier = Modifier.weight(1f)
            )
            Text("${strokeWidth.toInt()}px")
        }
    }
}

@Composable
private fun ApplyButton(label: String, onClick: () -> Unit) {
    Button(
        onClick = onClick,
        modifier = Modifier.height(40.dp),
        contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
    ) {
        Text(label, style = MaterialTheme.typography.labelMedium)
    }
}

@Composable
private fun NumericField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    label: String? = null,
    suffix: String? = null,
    onDone: () -> Unit = {}
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier.height(52.dp),
        label = label?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
        suffix = suffix?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
        singleLine = true,
        textStyle = MaterialTheme.typography.bodySmall,
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Decimal,
            imeAction = ImeAction.Done
        ),
        keyboardActions = KeyboardActions(onDone = { onDone() })
    )
}

private fun formatDecimal(value: Float): String {
    return if (value == value.roundToInt().toFloat()) {
        value.roundToInt().toString()
    } else {
        String.format("%.2f", value)
    }
}

@Composable
private fun EditActionButton(label: String, onClick: () -> Unit) {
    Surface(
        modifier = Modifier
            .size(36.dp)
            .clip(RoundedCornerShape(6.dp))
            .clickable(onClick = onClick),
        color = Color(0xFFE3F2FD),
        shape = RoundedCornerShape(6.dp)
    ) {
        Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
            Text(label, fontSize = 16.sp, color = Color(0xFF1565C0))
        }
    }
}

@Composable
private fun ToolButton(
    text: String,
    selected: Boolean,
    onClick: () -> Unit
) {
    Surface(
        modifier = Modifier
            .clip(RoundedCornerShape(8.dp))
            .clickable(onClick = onClick),
        color = if (selected) Color(0xFF2196F3) else Color.Transparent,
        shape = RoundedCornerShape(8.dp)
    ) {
        Text(
            text = text,
            modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
            color = if (selected) Color.White else Color.Black
        )
    }
}

app\src\main\java\com\accident\scene\diagram\DrawingLogger.kt

bash 复制代码
package com.accident.scene.diagram

import android.content.Context
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

class DrawingLogger(private val context: Context) {
    private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
    private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())

    private val logFile: File by lazy {
        val timestamp = fileNameFormat.format(Date())
        File(context.getExternalFilesDir(null), "drawing_log_$timestamp.txt")
    }

    fun log(operation: String, details: String) {
        val timestamp = dateFormat.format(Date())
        val logEntry = "[$timestamp] $operation: $details\n"
        try {
            logFile.appendText(logEntry)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    val logFilePath: String
        get() = logFile.absolutePath

    fun logSaveImage(imagePath: String) {
        log("SAVE_IMAGE", "path=$imagePath")
    }

    fun logAddElement(element: DrawElement) {
        val details = when (element) {
            is DrawElement.Rectangle ->
                "x=${element.position.x}, y=${element.position.y}, width=${element.width}, height=${element.height}"
            is DrawElement.Circle ->
                "x=${element.position.x}, y=${element.position.y}, radius=${element.radius}"
            is DrawElement.Triangle ->
                "x=${element.position.x}, y=${element.position.y}, size=${element.size}"
            is DrawElement.Line ->
                "x1=${element.position.x}, y1=${element.position.y}, x2=${element.endPosition.x}, y2=${element.endPosition.y}"
            is DrawElement.DashedLine ->
                "x1=${element.position.x}, y1=${element.position.y}, x2=${element.endPosition.x}, y2=${element.endPosition.y}"
            is DrawElement.TextElement ->
                "x=${element.position.x}, y=${element.position.y}, text=${element.text}"
            is DrawElement.IconElement ->
                "x=${element.position.x}, y=${element.position.y}, uri=${element.iconUri}"
        }
        log("ADD_${element.javaClass.simpleName.uppercase()}", details)
    }

    fun logRotate(elementId: String, angle: Float) {
        log("ROTATE", "id=$elementId, angle=$angle")
    }

    fun logScale(elementId: String, scale: Float) {
        log("SCALE", "id=$elementId, scale=$scale")
    }

    fun logMove(elementId: String, newPosition: androidx.compose.ui.geometry.Offset) {
        log("MOVE", "id=$elementId, x=${newPosition.x}, y=${newPosition.y}")
    }

    fun logDelete(elementId: String) {
        log("DELETE", "id=$elementId")
    }
}

app\src\main\java\com\accident\scene\diagram\CanvasBitmapRenderer.kt

bash 复制代码
package com.accident.scene.diagram

import android.graphics.Bitmap
import android.graphics.Paint
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.sqrt

object CanvasBitmapRenderer {
    fun render(
        elements: List<DrawElement>,
        width: Int,
        height: Int,
        iconBitmaps: Map<String, Bitmap>,
        backgroundBitmap: Bitmap?,
        density: Density
    ): Bitmap {
        val imageBitmap = ImageBitmap(width, height)
        val canvas = Canvas(imageBitmap)
        val drawScope = CanvasDrawScope()

        drawScope.draw(
            density = density,
            layoutDirection = LayoutDirection.Ltr,
            canvas = canvas,
            size = Size(width.toFloat(), height.toFloat())
        ) {
            drawRect(color = Color.White, size = Size(width.toFloat(), height.toFloat()))
            backgroundBitmap?.let { with(CanvasDrawUtils) { drawBackgroundFill(it) } }
            elements.forEach { element ->
                drawElement(element, iconBitmaps[element.id], density.density)
            }
        }

        return imageBitmap.asAndroidBitmap()
    }

    private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawElement(
        element: DrawElement,
        iconBitmap: Bitmap?,
        densityValue: Float
    ) {
        rotate(element.rotation, element.position) {
            scale(element.scale, element.position) {
                when (element) {
                    is DrawElement.Rectangle -> drawRect(
                        color = element.color,
                        topLeft = element.position,
                        size = Size(element.width, element.height),
                        style = Stroke(width = element.strokeWidth)
                    )
                    is DrawElement.Circle -> drawCircle(
                        color = element.color,
                        center = element.position,
                        radius = element.radius,
                        style = Stroke(width = element.strokeWidth)
                    )
                    is DrawElement.Triangle -> {
                        val path = Path().apply {
                            moveTo(element.position.x, element.position.y - element.size / 2)
                            lineTo(element.position.x + element.size / 2, element.position.y + element.size / 2)
                            lineTo(element.position.x - element.size / 2, element.position.y + element.size / 2)
                            close()
                        }
                        drawPath(path, element.color, style = Stroke(width = element.strokeWidth))
                    }
                    is DrawElement.Line -> drawLine(
                        color = element.color,
                        start = element.position,
                        end = element.endPosition,
                        strokeWidth = element.strokeWidth
                    )
                    is DrawElement.DashedLine -> drawDashedLine(
                        element.color,
                        element.position,
                        element.endPosition,
                        element.strokeWidth
                    )
                    is DrawElement.TextElement -> {
                        drawContext.canvas.nativeCanvas.drawText(
                            element.text,
                            element.position.x,
                            element.position.y,
                            Paint().apply {
                                color = element.color.toArgb()
                                textSize = element.fontSize * densityValue
                                isAntiAlias = true
                            }
                        )
                    }
                    is DrawElement.IconElement -> {
                        iconBitmap?.let { bitmap ->
                            val scaledWidth = element.width
                            val scaledHeight = element.height
                            drawContext.canvas.nativeCanvas.drawBitmap(
                                bitmap,
                                null,
                                android.graphics.RectF(
                                    element.position.x,
                                    element.position.y,
                                    element.position.x + scaledWidth,
                                    element.position.y + scaledHeight
                                ),
                                null
                            )
                        }
                    }
                }
            }
        }
    }

    private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawDashedLine(
        color: Color,
        start: Offset,
        end: Offset,
        strokeWidth: Float
    ) {
        val dx = end.x - start.x
        val dy = end.y - start.y
        val distance = sqrt(dx * dx + dy * dy)
        if (distance <= 0f) return

        val dashLength = 10f
        val gapLength = 5f
        var currentDistance = 0f
        var drawing = true

        while (currentDistance < distance) {
            val segmentLength = if (drawing) dashLength else gapLength
            val nextDistance = minOf(currentDistance + segmentLength, distance)
            val startFraction = currentDistance / distance
            val endFraction = nextDistance / distance

            if (drawing) {
                drawLine(
                    color = color,
                    start = Offset(start.x + dx * startFraction, start.y + dy * startFraction),
                    end = Offset(start.x + dx * endFraction, start.y + dy * endFraction),
                    strokeWidth = strokeWidth
                )
            }
            currentDistance = nextDistance
            drawing = !drawing
        }
    }
}

app\src\main\java\com\accident\scene\diagram\IconAssets.kt

bash 复制代码
package com.accident.scene.diagram

import android.content.Context
import android.net.Uri

object IconAssets {
    fun listIcons(context: Context): List<Pair<String, Uri>> {
        return context.assets.list("icon")
            ?.filter { it.endsWith(".jpg", ignoreCase = true) || it.endsWith(".png", ignoreCase = true) }
            ?.sorted()
            ?.map { fileName ->
                val name = fileName.substringBeforeLast('.')
                name to Uri.parse("file:///android_asset/icon/$fileName")
            }
            ?: emptyList()
    }
}

app\src\main\java\com\accident\scene\diagram\DrawingElements.kt

bash 复制代码
package com.accident.scene.diagram

import android.net.Uri
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color

sealed class DrawElement(
    open val id: String,
    open val position: Offset,
    open val rotation: Float = 0f,
    open val scale: Float = 1f
) {
    data class Rectangle(
        override val id: String,
        override val position: Offset,
        val width: Float,
        val height: Float,
        val color: Color = Color.Black,
        val strokeWidth: Float = 2f,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)

    data class Circle(
        override val id: String,
        override val position: Offset,
        val radius: Float,
        val color: Color = Color.Black,
        val strokeWidth: Float = 2f,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)

    data class Triangle(
        override val id: String,
        override val position: Offset,
        val size: Float,
        val color: Color = Color.Black,
        val strokeWidth: Float = 2f,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)

    data class Line(
        override val id: String,
        override val position: Offset,
        val endPosition: Offset,
        val color: Color = Color.Black,
        val strokeWidth: Float = 2f,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)

    data class DashedLine(
        override val id: String,
        override val position: Offset,
        val endPosition: Offset,
        val color: Color = Color.Black,
        val strokeWidth: Float = 2f,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)

    data class TextElement(
        override val id: String,
        override val position: Offset,
        val text: String,
        val fontSize: Float = 24f,
        val color: Color = Color.Black,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)

    data class IconElement(
        override val id: String,
        override val position: Offset,
        val iconUri: Uri,
        val width: Float = 80f,
        val height: Float = 80f,
        override val rotation: Float = 0f,
        override val scale: Float = 1f
    ) : DrawElement(id, position, rotation, scale)
}

enum class DrawingTool {
    SELECT, RECTANGLE, CIRCLE, TRIANGLE, LINE, DASHED_LINE, TEXT, ICON
}

data class DrawingState(
    val elements: List<DrawElement> = emptyList(),
    val selectedElementId: String? = null,
    val currentTool: DrawingTool = DrawingTool.SELECT,
    val currentColor: Color = Color.Black,
    val currentStrokeWidth: Float = 2f,
    val isDrawing: Boolean = false,
    val drawStartPosition: Offset? = null,
    val backgroundUri: Uri = CanvasDrawUtils.DEFAULT_BACKGROUND_URI,
    val pendingIconUri: Uri? = null,
    /** 比例尺:每米对应的像素数,默认 0.5px/米 */
    val pixelsPerMeter: Float = 0.5f
)

app\src\main\java\com\accident\scene\diagram\DrawingCanvas.kt

bash 复制代码
package com.accident.scene.diagram

import android.graphics.Bitmap
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import kotlin.math.atan2
import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

@Composable
fun DrawingCanvas(
    modifier: Modifier = Modifier,
    state: DrawingState,
    onDrawStart: (Offset) -> Unit,
    onDrawMove: (Offset) -> Unit,
    onDrawEnd: (Offset) -> Unit,
    onElementSelected: (String?) -> Unit,
    onElementMoved: (String, Offset) -> Unit,
    onElementRotated: (String, Float) -> Unit,
    onElementScaled: (String, Float) -> Unit,
    onElementRotatedBy: (String, Float) -> Unit = { _, _ -> },
    onElementScaledBy: (String, Float) -> Unit = { _, _ -> },
    shiftPressed: Boolean = false,
    onPlacePendingIcon: (Offset) -> Unit = {},
    onCanvasInteraction: () -> Unit = {},
    onCanvasSizeChanged: (IntSize) -> Unit = {}
) {
    val density = LocalDensity.current
    val context = LocalContext.current

    var canvasSize by remember { mutableStateOf(IntSize.Zero) }
    var iconBitmaps by remember { mutableStateOf<Map<String, Bitmap>>(emptyMap()) }
    var backgroundBitmap by remember { mutableStateOf<Bitmap?>(null) }
    var previewEnd by remember { mutableStateOf<Offset?>(null) }

    LaunchedEffect(state.backgroundUri) {
        try {
            val loader = ImageLoader(context)
            val request = ImageRequest.Builder(context)
                .data(state.backgroundUri)
                .allowHardware(false)
                .build()
            val result = loader.execute(request)
            if (result is SuccessResult) {
                backgroundBitmap = result.drawable.toBitmap()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    LaunchedEffect(state.elements) {
        val iconElements = state.elements.filterIsInstance<DrawElement.IconElement>()
        val newBitmaps = mutableMapOf<String, Bitmap>()
        for (element in iconElements) {
            if (!iconBitmaps.containsKey(element.id)) {
                try {
                    val loader = ImageLoader(context)
                    val request = ImageRequest.Builder(context)
                        .data(element.iconUri)
                        .allowHardware(false)
                        .build()
                    val result = loader.execute(request)
                    if (result is SuccessResult) {
                        newBitmaps[element.id] = result.drawable.toBitmap()
                    }
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            } else {
                newBitmaps[element.id] = iconBitmaps[element.id]!!
            }
        }
        iconBitmaps = newBitmaps
    }

    Canvas(
        modifier = modifier
            .fillMaxSize()
            .background(Color(0xFFF5F5F5))
            // 鼠标滚轮缩放(Final 阶段,不干扰触摸手势)
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        val event = awaitPointerEvent(PointerEventPass.Final)
                        val hasMouseScroll = event.changes.any {
                            it.type == PointerType.Mouse && it.scrollDelta != Offset.Zero
                        }
                        if (!hasMouseScroll) continue
                        val selectedId = state.selectedElementId ?: continue
                        val scrollDelta = event.changes.fold(Offset.Zero) { acc, change ->
                            acc + change.scrollDelta
                        }
                        if (scrollDelta.y != 0f) {
                            onCanvasInteraction()
                            val factor = if (scrollDelta.y > 0) 0.92f else 1.08f
                            onElementScaledBy(selectedId, factor)
                        }
                    }
                }
            }
            // 图例放置:选择图例后点击画布放置
            .pointerInput(state.pendingIconUri) {
                if (state.pendingIconUri != null) {
                    detectTapGestures { offset ->
                        onCanvasInteraction()
                        onPlacePendingIcon(offset)
                    }
                }
            }
            // 选择模式:单指拖拽/点击 + 双指旋转缩放(并行,互不取消)
            .pointerInput(state.currentTool, shiftPressed) {
                if (state.currentTool == DrawingTool.SELECT) {
                    val elements = state.elements
                    coroutineScope {
                        launch {
                            detectTransformGestures(
                                panZoomLock = false
                            ) { _, _, zoom, rotation ->
                                val selectedId = state.selectedElementId ?: return@detectTransformGestures
                                onCanvasInteraction()
                                // 单指移动由 detectDragGestures 处理,此处仅处理双指旋转/缩放
                                if (rotation != 0f) {
                                    onElementRotatedBy(selectedId, rotation)
                                }
                                if (zoom != 1f) {
                                    onElementScaledBy(selectedId, zoom)
                                }
                            }
                        }
                        launch {
                            var dragTargetId: String? = null
                            var rotateDragStartAngle = 0f
                            var rotateElementBaseRotation = 0f
                            var rotateCenter = Offset.Zero

                            detectDragGestures(
                                onDragStart = { offset ->
                                    onCanvasInteraction()
                                    val tappedElement = findElementAtPosition(elements, offset)
                                    dragTargetId = tappedElement?.id
                                    onElementSelected(tappedElement?.id)
                                    if (tappedElement != null) {
                                        rotateCenter = CanvasDrawUtils.getElementCenter(tappedElement)
                                        rotateDragStartAngle = atan2(
                                            offset.y - rotateCenter.y,
                                            offset.x - rotateCenter.x
                                        )
                                        rotateElementBaseRotation = tappedElement.rotation
                                    }
                                },
                                onDrag = { change, dragAmount ->
                                    change.consume()
                                    val targetId = dragTargetId ?: return@detectDragGestures
                                    onCanvasInteraction()

                                    if (shiftPressed) {
                                        val currentAngle = atan2(
                                            change.position.y - rotateCenter.y,
                                            change.position.x - rotateCenter.x
                                        )
                                        onElementRotated(
                                            targetId,
                                            rotateElementBaseRotation + (currentAngle - rotateDragStartAngle)
                                        )
                                    } else {
                                        onElementMoved(targetId, dragAmount)
                                    }
                                },
                                onDragEnd = { dragTargetId = null },
                                onDragCancel = { dragTargetId = null }
                            )
                        }
                        launch {
                            detectTapGestures { offset ->
                                onCanvasInteraction()
                                val tappedElement = findElementAtPosition(elements, offset)
                                onElementSelected(tappedElement?.id)
                            }
                        }
                    }
                }
            }
            // 绘图模式:单指拖拽绘制
            .pointerInput(state.currentTool) {
                if (state.currentTool != DrawingTool.SELECT &&
                    state.currentTool != DrawingTool.TEXT &&
                    state.currentTool != DrawingTool.ICON
                ) {
                    detectDragGestures(
                        onDragStart = { offset ->
                            onCanvasInteraction()
                            previewEnd = offset
                            onDrawStart(offset)
                        },
                        onDrag = { change, _ ->
                            change.consume()
                            previewEnd = change.position
                            onDrawMove(change.position)
                        },
                        onDragEnd = {
                            previewEnd?.let { onDrawEnd(it) }
                            previewEnd = null
                        },
                        onDragCancel = { previewEnd = null }
                    )
                }
            }
    ) {
        canvasSize = IntSize(size.width.toInt(), size.height.toInt())
        onCanvasSizeChanged(canvasSize)

        backgroundBitmap?.let { with(CanvasDrawUtils) { drawBackgroundFill(it) } }

        state.elements.forEach { element ->
            val isSelected = element.id == state.selectedElementId
            drawElement(element, isSelected, iconBitmaps[element.id], density.density)
        }

        if (state.isDrawing && state.drawStartPosition != null && previewEnd != null) {
            drawPreview(
                tool = state.currentTool,
                start = state.drawStartPosition!!,
                end = previewEnd!!,
                color = state.currentColor,
                strokeWidth = state.currentStrokeWidth
            )
        }
    }
}

private fun DrawScope.drawPreview(
    tool: DrawingTool,
    start: Offset,
    end: Offset,
    color: Color,
    strokeWidth: Float
) {
    when (tool) {
        DrawingTool.RECTANGLE -> {
            val width = kotlin.math.abs(end.x - start.x)
            val height = kotlin.math.abs(end.y - start.y)
            val x = minOf(start.x, end.x)
            val y = minOf(start.y, end.y)
            drawRect(color, Offset(x, y), Size(width, height), style = Stroke(width = strokeWidth))
        }
        DrawingTool.CIRCLE -> {
            val radius = sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y))
            drawCircle(color = color, center = start, radius = radius, style = Stroke(width = strokeWidth))
        }
        DrawingTool.TRIANGLE -> {
            val size = sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y))
            drawTriangle(start, size, color, strokeWidth)
        }
        DrawingTool.LINE -> drawLine(color, start, end, strokeWidth = strokeWidth)
        DrawingTool.DASHED_LINE -> drawDashedLine(color, start, end, strokeWidth)
        else -> Unit
    }
}

private fun findElementAtPosition(elements: List<DrawElement>, position: Offset): DrawElement? {
    return elements.lastOrNull { element ->
        getElementBounds(element).contains(position)
    }
}

private fun getElementBounds(element: DrawElement): Rect {
    return when (element) {
        is DrawElement.Rectangle -> Rect(
            element.position.x,
            element.position.y,
            element.position.x + element.width * element.scale,
            element.position.y + element.height * element.scale
        )
        is DrawElement.Circle -> {
            val r = element.radius * element.scale
            Rect(
                element.position.x - r,
                element.position.y - r,
                element.position.x + r,
                element.position.y + r
            )
        }
        is DrawElement.Triangle -> {
            val size = element.size * element.scale
            Rect(
                element.position.x - size / 2,
                element.position.y - size / 2,
                element.position.x + size / 2,
                element.position.y + size / 2
            )
        }
        is DrawElement.Line, is DrawElement.DashedLine -> {
            val start = element.position
            val end = when (element) {
                is DrawElement.Line -> element.endPosition
                is DrawElement.DashedLine -> element.endPosition
                else -> start
            }
            Rect(
                minOf(start.x, end.x) - 20f,
                minOf(start.y, end.y) - 20f,
                maxOf(start.x, end.x) + 20f,
                maxOf(start.y, end.y) + 20f
            )
        }
        is DrawElement.TextElement -> Rect(
            element.position.x,
            element.position.y - element.fontSize * element.scale,
            element.position.x + element.text.length * element.fontSize * 0.6f * element.scale,
            element.position.y + element.fontSize * element.scale
        )
        is DrawElement.IconElement -> Rect(
            element.position.x,
            element.position.y,
            element.position.x + element.width * element.scale,
            element.position.y + element.height * element.scale
        )
    }
}

private fun DrawScope.drawElement(
    element: DrawElement,
    isSelected: Boolean,
    iconBitmap: Bitmap?,
    densityValue: Float
) {
    val selectionColor = Color(0xFFFF9800)

    rotate(element.rotation, element.position) {
        scale(element.scale, element.position) {
            when (element) {
                is DrawElement.Rectangle -> drawRect(
                    color = element.color,
                    topLeft = element.position,
                    size = Size(element.width, element.height),
                    style = Stroke(width = element.strokeWidth)
                )
                is DrawElement.Circle -> drawCircle(
                    color = element.color,
                    center = element.position,
                    radius = element.radius,
                    style = Stroke(width = element.strokeWidth)
                )
                is DrawElement.Triangle -> drawTriangle(
                    position = element.position,
                    size = element.size,
                    color = element.color,
                    strokeWidth = element.strokeWidth
                )
                is DrawElement.Line -> drawLine(
                    color = element.color,
                    start = element.position,
                    end = element.endPosition,
                    strokeWidth = element.strokeWidth
                )
                is DrawElement.DashedLine -> drawDashedLine(
                    color = element.color,
                    start = element.position,
                    end = element.endPosition,
                    strokeWidth = element.strokeWidth
                )
                is DrawElement.TextElement -> {
                    drawContext.canvas.nativeCanvas.drawText(
                        element.text,
                        element.position.x,
                        element.position.y,
                        Paint().apply {
                            color = element.color.toArgb()
                            textSize = element.fontSize * densityValue
                            isAntiAlias = true
                        }
                    )
                }
                is DrawElement.IconElement -> {
                    iconBitmap?.let { bitmap ->
                        drawContext.canvas.nativeCanvas.drawBitmap(
                            bitmap,
                            null,
                            android.graphics.RectF(
                                element.position.x,
                                element.position.y,
                                element.position.x + element.width,
                                element.position.y + element.height
                            ),
                            null
                        )
                    }
                }
            }
        }
    }

    if (isSelected) {
        val bounds = getElementBounds(element)
        drawRect(
            color = selectionColor,
            topLeft = Offset(bounds.left - 4, bounds.top - 4),
            size = Size(bounds.width + 8, bounds.height + 8),
            style = Stroke(width = 2f)
        )
    }
}

private fun DrawScope.drawTriangle(
    position: Offset,
    size: Float,
    color: Color,
    strokeWidth: Float
) {
    val path = Path().apply {
        moveTo(position.x, position.y - size / 2)
        lineTo(position.x + size / 2, position.y + size / 2)
        lineTo(position.x - size / 2, position.y + size / 2)
        close()
    }
    drawPath(path = path, color = color, style = Stroke(width = strokeWidth))
}

private fun DrawScope.drawDashedLine(
    color: Color,
    start: Offset,
    end: Offset,
    strokeWidth: Float
) {
    val dx = end.x - start.x
    val dy = end.y - start.y
    val distance = sqrt(dx * dx + dy * dy)
    if (distance <= 0f) return

    val dashLength = 10f
    val gapLength = 5f
    var currentDistance = 0f
    var drawing = true

    while (currentDistance < distance) {
        val segmentLength = if (drawing) dashLength else gapLength
        val nextDistance = minOf(currentDistance + segmentLength, distance)
        val startFraction = currentDistance / distance
        val endFraction = nextDistance / distance

        if (drawing) {
            drawLine(
                color = color,
                start = Offset(start.x + dx * startFraction, start.y + dy * startFraction),
                end = Offset(start.x + dx * endFraction, start.y + dy * endFraction),
                strokeWidth = strokeWidth
            )
        }
        currentDistance = nextDistance
        drawing = !drawing
    }
}

app\src\main\java\com\accident\scene\diagram\CanvasDrawUtils.kt

bash 复制代码
package com.accident.scene.diagram

import android.graphics.Bitmap
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas

object CanvasDrawUtils {
    const val DEFAULT_BACKGROUND_ASSET = "icon/A型图.jpg"
    val DEFAULT_BACKGROUND_URI get() = android.net.Uri.parse("file:///android_asset/$DEFAULT_BACKGROUND_ASSET")

    fun DrawScope.drawBackgroundFill(bitmap: Bitmap) {
        drawContext.canvas.nativeCanvas.drawBitmap(
            bitmap,
            null,
            android.graphics.RectF(0f, 0f, size.width, size.height),
            null
        )
    }

    fun DrawScope.drawBackgroundFit(bitmap: Bitmap) {
        val bw = bitmap.width.toFloat()
        val bh = bitmap.height.toFloat()
        if (bw <= 0f || bh <= 0f) return
        val scale = minOf(size.width / bw, size.height / bh)
        val w = bw * scale
        val h = bh * scale
        val left = (size.width - w) / 2f
        val top = (size.height - h) / 2f
        drawContext.canvas.nativeCanvas.drawBitmap(
            bitmap,
            null,
            android.graphics.RectF(left, top, left + w, top + h),
            null
        )
    }

    fun getElementCenter(element: DrawElement): Offset {
        return when (element) {
            is DrawElement.Rectangle -> Offset(
                element.position.x + element.width * element.scale / 2f,
                element.position.y + element.height * element.scale / 2f
            )
            is DrawElement.Circle -> element.position
            is DrawElement.Triangle -> element.position
            is DrawElement.Line, is DrawElement.DashedLine -> {
                val end = when (element) {
                    is DrawElement.Line -> element.endPosition
                    is DrawElement.DashedLine -> element.endPosition
                    else -> element.position
                }
                Offset(
                    (element.position.x + end.x) / 2f,
                    (element.position.y + end.y) / 2f
                )
            }
            is DrawElement.TextElement -> Offset(
                element.position.x + element.text.length * element.fontSize * 0.3f * element.scale,
                element.position.y - element.fontSize * element.scale / 2f
            )
            is DrawElement.IconElement -> Offset(
                element.position.x + element.width * element.scale / 2f,
                element.position.y + element.height * element.scale / 2f
            )
        }
    }
}
复制代码
        val radius = sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y))
        drawCircle(color, start, radius, style = Stroke(width = strokeWidth))
        drawCircle(color = color, center = start, radius = radius, style = Stroke(width = strokeWidth))
    }

已完成 事故现场绘图 Android 应用,并成功构建、安装到模拟器。

关于 Gradle 9.2.0

可以用。 已验证该路径可用:

D:\ide\MavenRepository\wrapper\dists\gradle-9.2.0-bin\11i5gvueggl8a5cioxuftxrik\gradle-9.2.0\bin\gradle.bat

Everything MCP 还找到了本机其他 Gradle 版本(7.2、8.4、8.11.1、9.2.0)。项目已将 wrapper 升级到 Gradle 9.2.0(AGP 8.7 需要 Gradle ≥ 8.9)。

已实现功能

功能 说明

基础图形

矩形、圆、三角形、直线、虚线(拖拽绘制)

文字

对话框输入后添加到画布

图标

从 icon/ 目录自动加载 30 个 图例到 assets

选择/移动

点击选中,拖拽移动

旋转/缩放

双指手势操作选中元素

绘制日志

写入 Android/data/.../files/drawing_log_*.txt

保存图片

PNG 保存到 Pictures/AccidentSceneDiagram/

构建产物

Debug APK(已安装到模拟器 emulator-5554)

app\build\outputs\apk\debug\app-debug.apk(约 10.7 MB)

Release 签名 APK

app\build\outputs\apk\release\app-release.apk(约 7.5 MB)

签名配置:app/release-keystore.jks(alias: key0,密码: android)

使用说明

绘制图形:选择工具后在画布上拖拽

选择/编辑:选「选择」→ 点击元素 → 拖拽移动 / 双指旋转缩放

添加图标:选「图标」→ 从 30 个图例中选择

保存:点右上角「保存」

构建命令

cd d:\code\kotlin\AccidentSceneDiagram

$env:GRADLE_USER_HOME = "d:\code\kotlin\AccidentSceneDiagram.gradle_home"

.\gradlew.bat assembleRelease # 发行版

.\gradlew.bat assembleDebug # 调试版

也可直接使用你本地的 Gradle 9.2.0:

& "D:\ide\MavenRepository\wrapper\dists\gradle-9.2.0-bin\11i5gvueggl8a5cioxuftxrik\gradle-9.2.0\bin\gradle.bat" assembleRelease

应用已在模拟器上启动。

相关推荐
飞舞哲1 小时前
三维点云最小二乘拟合MATLAB程序
开发语言·算法·matlab
有点。1 小时前
C++(贪心算法二)
开发语言·c++·贪心算法
meilindehuzi_a1 小时前
透视 V8 底部:从物理内存到函数式哲学,重新解构 JavaScript 数组
开发语言·javascript·ecmascript
jllllyuz1 小时前
HVDC 高压直流输电系统 MATLAB/Simulink 仿真全集
开发语言·matlab
我命由我123451 小时前
Windows 操作系统 - Windows 查看防火墙是否开启、Windows 查看防火墙放行端口
java·运维·开发语言·windows·java-ee·操作系统·运维开发
天天进步20151 小时前
Python全栈项目--基于Python的数据库管理工具
开发语言·数据库·python
YHHLAI1 小时前
JavaScript 数据结构精讲:数组底层与实战避坑
开发语言·javascript·数据结构
有点。1 小时前
C++贪心算法一(练习题)
开发语言·c++·贪心算法
threelab1 小时前
Three.js 几何图形变换 | 三维可视化 / AI 提示词
开发语言·前端·javascript·人工智能·3d·着色器