refactor: placement body PNG → Canvas 실루엣 (CKLaw Bezier torso)
placement_body.png 제거, CKLaw BladderSeekScreen의 해부학적 Canvas 실루엣으로 교체 (갈비뼈, 배꼽, 치골 아치, hourglass torso). sensor_device.png는 유지. 센서 위치 방광 타원 안쪽으로 조정. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+110
-31
@@ -149,7 +149,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// ── 하복부 이미지 + 센서 + 방향 가이드 ──
|
||||
// ── 하복부 Canvas 실루엣 + 센서 + 방향 가이드 ──
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -157,50 +157,129 @@ fun PlacementGuideView(appState: AppState) {
|
||||
.padding(top = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 배경 이미지 (하복부 실루엣)
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.placement_body),
|
||||
contentDescription = "Body",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
|
||||
alpha = if (isGreenZone) 0.6f else 0.9f
|
||||
)
|
||||
|
||||
// 오버레이: 방광 + 센서 + 화살표
|
||||
// 하복부 실루엣 (Canvas — CKLaw body silhouette)
|
||||
val bodyAlpha = if (isGreenZone) 0.6f else 0.9f
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val cx = w / 2f
|
||||
val greenZone = isGreenZone
|
||||
|
||||
// 방광 위치 (이미지의 배꼽~치골 사이)
|
||||
val bladderRy = h * 0.05f
|
||||
val bladderRx = w * 0.12f
|
||||
val bladderCy = h * 0.48f + bladderRy // 위쪽 경계 고정
|
||||
// Body silhouette palette
|
||||
val skinFill = Color(0xFFF2C5A0).copy(alpha = bodyAlpha)
|
||||
val skinStroke = Color(0xFFBF947A).copy(alpha = bodyAlpha)
|
||||
val skinShade = Color(0xFF8E6647).copy(alpha = bodyAlpha)
|
||||
val navelInk = Color(0xFF5A3D26).copy(alpha = bodyAlpha)
|
||||
|
||||
// ── Anatomical landmarks (vertical %) ──
|
||||
val chestY = h * 0.06f
|
||||
val waistY = h * 0.42f
|
||||
val hipY = h * 0.76f
|
||||
val pubisY = h * 0.96f
|
||||
val chestW = w * 0.42f
|
||||
val waistW = w * 0.32f
|
||||
val hipW = w * 0.46f
|
||||
|
||||
// ── Torso silhouette — hourglass via cubic bezier ──
|
||||
val bodyPath = Path().apply {
|
||||
moveTo(cx - chestW, chestY)
|
||||
cubicTo(
|
||||
cx - chestW * 1.05f, chestY + (waistY - chestY) * 0.45f,
|
||||
cx - waistW * 1.05f, waistY - (waistY - chestY) * 0.20f,
|
||||
cx - waistW, waistY)
|
||||
cubicTo(
|
||||
cx - waistW * 0.98f, waistY + (hipY - waistY) * 0.35f,
|
||||
cx - hipW * 0.95f, hipY - (hipY - waistY) * 0.20f,
|
||||
cx - hipW, hipY)
|
||||
cubicTo(
|
||||
cx - hipW * 0.92f, hipY + (pubisY - hipY) * 0.55f,
|
||||
cx - hipW * 0.45f, pubisY,
|
||||
cx, pubisY)
|
||||
cubicTo(
|
||||
cx + hipW * 0.45f, pubisY,
|
||||
cx + hipW * 0.92f, hipY + (pubisY - hipY) * 0.55f,
|
||||
cx + hipW, hipY)
|
||||
cubicTo(
|
||||
cx + hipW * 0.95f, hipY - (hipY - waistY) * 0.20f,
|
||||
cx + waistW * 0.98f, waistY + (hipY - waistY) * 0.35f,
|
||||
cx + waistW, waistY)
|
||||
cubicTo(
|
||||
cx + waistW * 1.05f, waistY - (waistY - chestY) * 0.20f,
|
||||
cx + chestW * 1.05f, chestY + (waistY - chestY) * 0.45f,
|
||||
cx + chestW, chestY)
|
||||
close()
|
||||
}
|
||||
drawPath(bodyPath, skinFill)
|
||||
drawPath(bodyPath, skinStroke, style = Stroke(width = 1.6f))
|
||||
|
||||
// ── Costal margin (갈비뼈 라인) ──
|
||||
val ribY = chestY + h * 0.10f
|
||||
val ribPathL = Path().apply {
|
||||
moveTo(cx - chestW * 0.85f, chestY + h * 0.04f)
|
||||
cubicTo(
|
||||
cx - chestW * 0.55f, chestY + h * 0.13f,
|
||||
cx - chestW * 0.20f, chestY + h * 0.13f,
|
||||
cx, ribY)
|
||||
}
|
||||
val ribPathR = Path().apply {
|
||||
moveTo(cx + chestW * 0.85f, chestY + h * 0.04f)
|
||||
cubicTo(
|
||||
cx + chestW * 0.55f, chestY + h * 0.13f,
|
||||
cx + chestW * 0.20f, chestY + h * 0.13f,
|
||||
cx, ribY)
|
||||
}
|
||||
drawPath(ribPathL, skinShade.copy(alpha = 0.55f * bodyAlpha),
|
||||
style = Stroke(width = 1.2f))
|
||||
drawPath(ribPathR, skinShade.copy(alpha = 0.55f * bodyAlpha),
|
||||
style = Stroke(width = 1.2f))
|
||||
|
||||
// ── Linea alba (복부 정중선) ──
|
||||
val navelY = waistY + (hipY - waistY) * 0.20f
|
||||
drawLine(skinShade.copy(alpha = 0.30f * bodyAlpha),
|
||||
Offset(cx, ribY + 4f), Offset(cx, navelY - 6f),
|
||||
strokeWidth = 1.0f)
|
||||
|
||||
// ── Navel (배꼽) ──
|
||||
drawCircle(navelInk, 3.5f, Offset(cx, navelY))
|
||||
drawCircle(skinShade.copy(alpha = 0.4f * bodyAlpha), 5.5f, Offset(cx, navelY),
|
||||
style = Stroke(width = 0.8f))
|
||||
|
||||
// ── Pubic arch (치골 아치) ──
|
||||
val archY = pubisY - h * 0.04f
|
||||
val archPath = Path().apply {
|
||||
moveTo(cx - hipW * 0.30f, archY)
|
||||
cubicTo(
|
||||
cx - hipW * 0.10f, archY + h * 0.025f,
|
||||
cx + hipW * 0.10f, archY + h * 0.025f,
|
||||
cx + hipW * 0.30f, archY)
|
||||
}
|
||||
drawPath(archPath, skinShade.copy(alpha = 0.45f * bodyAlpha),
|
||||
style = Stroke(width = 1.4f))
|
||||
|
||||
// ── 방광 타원 (배꼽~치골 사이) ──
|
||||
val bladderCy = (navelY + pubisY) / 2f
|
||||
val bladderRx = hipW * 0.42f
|
||||
val bladderRy = bladderRx * 0.70f
|
||||
val ovalColor = if (greenZone) Color(0xFF4CAF50) else Color.Cyan
|
||||
drawOval(
|
||||
color = if (greenZone) Color(0xFF4CAF50).copy(alpha = 0.15f) else Color.Cyan.copy(alpha = 0.12f),
|
||||
color = ovalColor.copy(alpha = 0.12f),
|
||||
topLeft = Offset(cx - bladderRx, bladderCy - bladderRy),
|
||||
size = Size(bladderRx * 2, bladderRy * 2)
|
||||
)
|
||||
drawOval(
|
||||
color = if (greenZone) Color(0xFF4CAF50).copy(alpha = 0.4f) else Color.Cyan.copy(alpha = 0.35f),
|
||||
color = ovalColor.copy(alpha = 0.4f),
|
||||
topLeft = Offset(cx - bladderRx, bladderCy - bladderRy),
|
||||
size = Size(bladderRx * 2, bladderRy * 2),
|
||||
style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)))
|
||||
)
|
||||
|
||||
// 센서 위치 계산 (이미지 오버레이용)
|
||||
val sensorW = 80f
|
||||
val sensorH = 50f
|
||||
val sensorY = bladderCy - sensorH / 2
|
||||
|
||||
// ── 방향 화살표 (타원 바깥에 크게) ──
|
||||
// ── 방향 화살표 (방광 타원 바깥) ──
|
||||
if (!greenZone) {
|
||||
val arrowColor = Color(0xFFFF5722)
|
||||
val arrowLen = 50f
|
||||
val arrowHead = 20f
|
||||
val sw = 7f
|
||||
val gap = 12f // 타원 경계에서 간격
|
||||
val gap = 12f
|
||||
|
||||
when {
|
||||
directionIcon.contains("up") -> {
|
||||
@@ -230,14 +309,14 @@ fun PlacementGuideView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Green 체크마크
|
||||
// ── Green 체크마크 ──
|
||||
if (greenZone) {
|
||||
drawCircle(Color(0xFF4CAF50).copy(alpha = 0.2f), radius = 35f, center = Offset(cx, sensorY - 30f))
|
||||
// ✓ 모양
|
||||
val checkCy = bladderCy - bladderRy - 40f
|
||||
drawCircle(Color(0xFF4CAF50).copy(alpha = 0.2f), radius = 35f, center = Offset(cx, checkCy))
|
||||
val checkPath = Path().apply {
|
||||
moveTo(cx - 15f, sensorY - 30f)
|
||||
lineTo(cx - 5f, sensorY - 20f)
|
||||
lineTo(cx + 15f, sensorY - 42f)
|
||||
moveTo(cx - 15f, checkCy)
|
||||
lineTo(cx - 5f, checkCy + 10f)
|
||||
lineTo(cx + 15f, checkCy - 12f)
|
||||
}
|
||||
drawPath(checkPath, Color(0xFF4CAF50), style = Stroke(width = 4f))
|
||||
}
|
||||
@@ -250,7 +329,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
modifier = Modifier
|
||||
.size(width = 200.dp, height = 125.dp)
|
||||
.align(Alignment.Center)
|
||||
.offset(x = 5.dp, y = 20.dp),
|
||||
.offset(x = 5.dp, y = 115.dp),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
|
||||
alpha = if (isGreenZone) 0.7f else 0.95f
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user