package service import ( "fmt" "math" "strings" "materialanalyzer/internal/model" ) func BuildSemantic(p model.Product, visual model.VisualFeatures, texture model.TextureFeatures) model.SemanticFeatures { family := MaterialFamily(p.Category, p.Material) color := colorFamily(visual.DominantRGB) finish := strings.TrimSpace(p.Finish) if finish == "" { finish = inferFinish(visual, texture) } variation := variationLevel(visual, texture) style := visualStyle(family, p, visual) grain := grainLabel(family, p, texture) description := fmt.Sprintf("%s %s %s flooring with %s finish, %s visual variation, and %s texture direction.", color, strings.ToLower(style), cleanMaterialLabel(p), strings.ToLower(finish), strings.ToLower(variation), strings.ToLower(grain)) tags := []string{family, color, style, finish, variation} if p.ColorTone != "" { tags = append(tags, p.ColorTone) } if p.Material != "" { tags = append(tags, p.Material) } return model.SemanticFeatures{ Provider: "local_rules_v2", Model: "rule_based", Description: description, MaterialType: family, SurfaceFinish: finish, GrainType: grain, ColorFamily: color, VisualStyle: style, Variation: variation, GlossLevel: glossLevel(finish, visual), Grain: grain, Tags: compactStrings(tags), Confidence: 0.72, FieldConfidence: map[string]float64{ "description": 0.68, "material_type": 0.85, "surface_finish": confidenceFromSource(p.Finish), "grain_type": 0.62, "color_family": 0.88, "visual_style": 0.72, "variation": 0.74, "gloss_level": 0.65, }, } } func MaterialFamily(category, material string) string { cat := strings.ToLower(category) mat := strings.ToLower(material) text := cat + " " + mat switch { case containsAnyText(text, "spc", "lvp", "wpc", "luxury vinyl", "vinyl", "rigid core", "pvc"): return "vinyl" case containsAnyText(text, "porcelain", "ceramic", "tile"): if containsAnyText(mat, "stone", "marble", "travertine", "slate", "limestone", "granite", "terrazzo") { return "stone_tile" } return "tile" case containsAnyText(text, "stone", "marble", "travertine", "slate", "limestone"): return "stone_tile" case containsAnyText(text, "hardwood", "engineered", "laminate", "oak", "maple", "hickory", "walnut", "pine", "birch", "cherry", "acacia", "bamboo"): return "wood" default: return "generic" } } func cleanMaterialLabel(p model.Product) string { for _, candidate := range []string{p.Material, p.Category, "material"} { candidate = strings.TrimSpace(candidate) if candidate != "" { return candidate } } return "material" } func colorFamily(rgb model.RGB) string { r, g, b := float64(rgb.R), float64(rgb.G), float64(rgb.B) maxV := math.Max(r, math.Max(g, b)) minV := math.Min(r, math.Min(g, b)) brightness := maxV / 255.0 chroma := (maxV - minV) / 255.0 if brightness < 0.18 { return "Black" } if brightness > 0.86 && chroma < 0.10 { return "White" } if chroma < 0.08 { if brightness < 0.42 { return "Dark Gray" } if brightness > 0.72 { return "Light Gray" } return "Gray" } if r > g*1.08 && g > b*1.05 { if brightness > 0.65 { return "Honey" } return "Brown" } if r > 150 && g > 125 && b > 95 && math.Abs(r-g) < 45 { return "Beige" } if b > r && b > g { return "Cool Gray" } return "Natural" } func inferFinish(visual model.VisualFeatures, texture model.TextureFeatures) string { if visual.Contrast < 0.12 && texture.TextureFrequency < 0.18 { return "Matte" } if visual.Brightness > 0.72 && texture.EdgeDensity < 0.08 { return "Satin" } return "Textured" } func glossLevel(finish string, visual model.VisualFeatures) string { lower := strings.ToLower(finish) switch { case strings.Contains(lower, "gloss"): return "High" case strings.Contains(lower, "satin"): return "Medium" case strings.Contains(lower, "matte"): return "Low" case visual.Brightness > 0.72 && visual.Contrast < 0.12: return "Medium" default: return "Low" } } func confidenceFromSource(v string) float64 { if strings.TrimSpace(v) == "" { return 0.55 } return 0.88 } func variationLevel(visual model.VisualFeatures, texture model.TextureFeatures) string { colorDistance := 0.0 if visual.DominantRGB != visual.SecondaryRGB { dr := float64(visual.DominantRGB.R - visual.SecondaryRGB.R) dg := float64(visual.DominantRGB.G - visual.SecondaryRGB.G) db := float64(visual.DominantRGB.B - visual.SecondaryRGB.B) colorDistance = math.Sqrt(dr*dr+dg*dg+db*db) / 441.67 } score := visual.Contrast*0.45 + texture.EdgeDensity*0.35 + colorDistance*0.20 switch { case score > 0.20 || texture.Entropy > 7.35: return "High" case score > 0.11 || texture.Entropy > 6.55: return "Medium" default: return "Low" } } func visualStyle(family string, p model.Product, visual model.VisualFeatures) string { text := strings.ToLower(p.StyleName + " " + p.Material + " " + p.Category + " " + p.ColorTone) switch family { case "wood", "vinyl": if containsAnyText(text, "weathered", "distressed", "reclaimed", "rustic") { return "Rustic" } if containsAnyText(text, "oak", "hickory", "maple", "pine", "walnut", "cherry") { return "Natural Wood" } case "tile", "stone_tile": if containsAnyText(text, "oak", "hickory", "maple", "pine", "walnut", "wood") { return "Wood Look Tile" } if containsAnyText(text, "marble") { return "Marble" } if containsAnyText(text, "slate", "travertine", "limestone", "stone") { return "Natural Stone" } return "Contemporary Tile" } if visual.Saturation < 0.12 { return "Neutral" } return "Natural" } func grainLabel(family string, p model.Product, texture model.TextureFeatures) string { if family == "tile" || family == "stone_tile" { text := strings.ToLower(p.Material + " " + p.StyleName) if containsAnyText(text, "oak", "hickory", "maple", "pine", "walnut", "wood") { return orientationLabel(texture.PrimaryOrientationDeg) + " " + inferSpecies(p) } if texture.OrientationVariance < 0.38 { return orientationLabel(texture.PrimaryOrientationDeg) + " Vein" } return "Mixed Vein" } species := inferSpecies(p) direction := orientationLabel(texture.PrimaryOrientationDeg) if texture.OrientationVariance > 0.70 { return "Mixed " + species } return direction + " " + species } func inferSpecies(p model.Product) string { text := strings.ToLower(p.WoodSpecies + " " + p.Material + " " + p.StyleName) for _, s := range []string{"oak", "maple", "hickory", "walnut", "pine", "birch", "cherry", "acacia", "bamboo", "ash", "elm", "teak"} { if strings.Contains(text, s) { return strings.Title(s) } } return "Grain" } func orientationLabel(deg float64) string { switch { case deg < 22.5 || deg >= 157.5: return "Horizontal" case deg < 67.5: return "Diagonal" case deg < 112.5: return "Vertical" default: return "Diagonal" } } func compactStrings(values []string) []string { seen := map[string]bool{} out := make([]string, 0, len(values)) for _, v := range values { v = strings.TrimSpace(v) if v == "" { continue } key := strings.ToLower(v) if seen[key] { continue } seen[key] = true out = append(out, v) } return out } func containsAnyText(text string, needles ...string) bool { for _, needle := range needles { if strings.Contains(text, needle) { return true } } return false }