diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2 new file mode 100644 index 000000000..35e24b319 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2 new file mode 100644 index 000000000..7d82bd561 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2 new file mode 100644 index 000000000..4da4bb115 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2 new file mode 100644 index 000000000..8a5044f22 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_thumb.webp b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_thumb.webp new file mode 100644 index 000000000..495ef6cae Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_thumb.webp differ diff --git a/apps/editor/public/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2 b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2 new file mode 100644 index 000000000..b194d72d5 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_polished/concrete_polished_normal_512.ktx2 b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_normal_512.ktx2 new file mode 100644 index 000000000..40409452b Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_polished/concrete_polished_roughness_512.ktx2 b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_roughness_512.ktx2 new file mode 100644 index 000000000..2f2d796c8 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_polished/concrete_polished_thumb.webp b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_thumb.webp new file mode 100644 index 000000000..e86c3df3c Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_polished/concrete_polished_thumb.webp differ diff --git a/apps/editor/public/material/concrete/concrete_raw/concrete_raw_ao_512.ktx2 b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_ao_512.ktx2 new file mode 100644 index 000000000..d42fed1cc Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_raw/concrete_raw_basecolor_512.ktx2 b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_basecolor_512.ktx2 new file mode 100644 index 000000000..a3dbedd35 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_raw/concrete_raw_normal_512.ktx2 b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_normal_512.ktx2 new file mode 100644 index 000000000..048e7384b Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_raw/concrete_raw_roughness_512.ktx2 b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_roughness_512.ktx2 new file mode 100644 index 000000000..1997dfa8b Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_raw/concrete_raw_thumb.webp b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_thumb.webp new file mode 100644 index 000000000..9dd8ae17a Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_raw/concrete_raw_thumb.webp differ diff --git a/apps/editor/public/material/concrete/plaster_painted/plaster_painted_ao_512.ktx2 b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_ao_512.ktx2 new file mode 100644 index 000000000..3b11c66a1 Binary files /dev/null and b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/plaster_painted/plaster_painted_basecolor_512.ktx2 b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_basecolor_512.ktx2 new file mode 100644 index 000000000..f058bc473 Binary files /dev/null and b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/plaster_painted/plaster_painted_normal_512.ktx2 b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_normal_512.ktx2 new file mode 100644 index 000000000..e4d75718d Binary files /dev/null and b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/plaster_painted/plaster_painted_roughness_512.ktx2 b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_roughness_512.ktx2 new file mode 100644 index 000000000..c23d72c3e Binary files /dev/null and b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/plaster_painted/plaster_painted_thumb.webp b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_thumb.webp new file mode 100644 index 000000000..dcc2c577c Binary files /dev/null and b/apps/editor/public/material/concrete/plaster_painted/plaster_painted_thumb.webp differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2 new file mode 100644 index 000000000..1f9fa9fcd Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2 new file mode 100644 index 000000000..3e265ea17 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2 new file mode 100644 index 000000000..0c700cd68 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2 new file mode 100644 index 000000000..e0804cba3 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_thumb.webp b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_thumb.webp new file mode 100644 index 000000000..f4c69c450 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_thumb.webp differ diff --git a/apps/editor/public/material/concrete/white_stucco/white_stucco_ao_512.ktx2 b/apps/editor/public/material/concrete/white_stucco/white_stucco_ao_512.ktx2 new file mode 100644 index 000000000..1f9fa9fcd Binary files /dev/null and b/apps/editor/public/material/concrete/white_stucco/white_stucco_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/white_stucco/white_stucco_basecolor_512.ktx2 b/apps/editor/public/material/concrete/white_stucco/white_stucco_basecolor_512.ktx2 new file mode 100644 index 000000000..65151883e Binary files /dev/null and b/apps/editor/public/material/concrete/white_stucco/white_stucco_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/white_stucco/white_stucco_normal_512.ktx2 b/apps/editor/public/material/concrete/white_stucco/white_stucco_normal_512.ktx2 new file mode 100644 index 000000000..0c700cd68 Binary files /dev/null and b/apps/editor/public/material/concrete/white_stucco/white_stucco_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/white_stucco/white_stucco_roughness_512.ktx2 b/apps/editor/public/material/concrete/white_stucco/white_stucco_roughness_512.ktx2 new file mode 100644 index 000000000..e0804cba3 Binary files /dev/null and b/apps/editor/public/material/concrete/white_stucco/white_stucco_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/white_stucco/white_stucco_thumb.webp b/apps/editor/public/material/concrete/white_stucco/white_stucco_thumb.webp new file mode 100644 index 000000000..bcc006cb3 Binary files /dev/null and b/apps/editor/public/material/concrete/white_stucco/white_stucco_thumb.webp differ diff --git a/apps/editor/public/material/fabric/blue_cotton/blue_cotton_ao_512.ktx2 b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_ao_512.ktx2 new file mode 100644 index 000000000..8cd1a49de Binary files /dev/null and b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_ao_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/blue_cotton/blue_cotton_basecolor_512.ktx2 b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_basecolor_512.ktx2 new file mode 100644 index 000000000..a85618b24 Binary files /dev/null and b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/blue_cotton/blue_cotton_normal_512.ktx2 b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_normal_512.ktx2 new file mode 100644 index 000000000..e318d1209 Binary files /dev/null and b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_normal_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/blue_cotton/blue_cotton_roughness_512.ktx2 b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_roughness_512.ktx2 new file mode 100644 index 000000000..60f45f254 Binary files /dev/null and b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/blue_cotton/blue_cotton_thumb.webp b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_thumb.webp new file mode 100644 index 000000000..6171aeb8a Binary files /dev/null and b/apps/editor/public/material/fabric/blue_cotton/blue_cotton_thumb.webp differ diff --git a/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2 b/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2 new file mode 100644 index 000000000..48cfd9ebe Binary files /dev/null and b/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_normal_512.ktx2 b/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_normal_512.ktx2 new file mode 100644 index 000000000..40c80b302 Binary files /dev/null and b/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_normal_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp b/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp new file mode 100644 index 000000000..945e4f097 Binary files /dev/null and b/apps/editor/public/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp differ diff --git a/apps/editor/public/material/fabric/red_velvet/red_velvet_ao_512.ktx2 b/apps/editor/public/material/fabric/red_velvet/red_velvet_ao_512.ktx2 new file mode 100644 index 000000000..d3ef0bc5c Binary files /dev/null and b/apps/editor/public/material/fabric/red_velvet/red_velvet_ao_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/red_velvet/red_velvet_basecolor_512.ktx2 b/apps/editor/public/material/fabric/red_velvet/red_velvet_basecolor_512.ktx2 new file mode 100644 index 000000000..f9a1fd4c0 Binary files /dev/null and b/apps/editor/public/material/fabric/red_velvet/red_velvet_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/red_velvet/red_velvet_normal_512.ktx2 b/apps/editor/public/material/fabric/red_velvet/red_velvet_normal_512.ktx2 new file mode 100644 index 000000000..378f5453c Binary files /dev/null and b/apps/editor/public/material/fabric/red_velvet/red_velvet_normal_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/red_velvet/red_velvet_roughness_512.ktx2 b/apps/editor/public/material/fabric/red_velvet/red_velvet_roughness_512.ktx2 new file mode 100644 index 000000000..85ed42ecb Binary files /dev/null and b/apps/editor/public/material/fabric/red_velvet/red_velvet_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/red_velvet/red_velvet_thumb.webp b/apps/editor/public/material/fabric/red_velvet/red_velvet_thumb.webp new file mode 100644 index 000000000..96f7b01d0 Binary files /dev/null and b/apps/editor/public/material/fabric/red_velvet/red_velvet_thumb.webp differ diff --git a/apps/editor/public/material/fabric/suede/suede_ao_512.ktx2 b/apps/editor/public/material/fabric/suede/suede_ao_512.ktx2 new file mode 100644 index 000000000..fed0781e2 Binary files /dev/null and b/apps/editor/public/material/fabric/suede/suede_ao_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/suede/suede_basecolor_512.ktx2 b/apps/editor/public/material/fabric/suede/suede_basecolor_512.ktx2 new file mode 100644 index 000000000..eb66e15e1 Binary files /dev/null and b/apps/editor/public/material/fabric/suede/suede_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/suede/suede_normal_512.ktx2 b/apps/editor/public/material/fabric/suede/suede_normal_512.ktx2 new file mode 100644 index 000000000..b89031068 Binary files /dev/null and b/apps/editor/public/material/fabric/suede/suede_normal_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/suede/suede_roughness_512.ktx2 b/apps/editor/public/material/fabric/suede/suede_roughness_512.ktx2 new file mode 100644 index 000000000..0425bad94 Binary files /dev/null and b/apps/editor/public/material/fabric/suede/suede_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/suede/suede_thumb.webp b/apps/editor/public/material/fabric/suede/suede_thumb.webp new file mode 100644 index 000000000..c0f204720 Binary files /dev/null and b/apps/editor/public/material/fabric/suede/suede_thumb.webp differ diff --git a/apps/editor/public/material/fabric/white_wool/white_wool_ao_512.ktx2 b/apps/editor/public/material/fabric/white_wool/white_wool_ao_512.ktx2 new file mode 100644 index 000000000..1f62bf145 Binary files /dev/null and b/apps/editor/public/material/fabric/white_wool/white_wool_ao_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/white_wool/white_wool_basecolor_512.ktx2 b/apps/editor/public/material/fabric/white_wool/white_wool_basecolor_512.ktx2 new file mode 100644 index 000000000..0fcfc783b Binary files /dev/null and b/apps/editor/public/material/fabric/white_wool/white_wool_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/white_wool/white_wool_normal_512.ktx2 b/apps/editor/public/material/fabric/white_wool/white_wool_normal_512.ktx2 new file mode 100644 index 000000000..12f069f59 Binary files /dev/null and b/apps/editor/public/material/fabric/white_wool/white_wool_normal_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/white_wool/white_wool_roughness_512.ktx2 b/apps/editor/public/material/fabric/white_wool/white_wool_roughness_512.ktx2 new file mode 100644 index 000000000..98db22632 Binary files /dev/null and b/apps/editor/public/material/fabric/white_wool/white_wool_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/white_wool/white_wool_thumb.webp b/apps/editor/public/material/fabric/white_wool/white_wool_thumb.webp new file mode 100644 index 000000000..2623dce87 Binary files /dev/null and b/apps/editor/public/material/fabric/white_wool/white_wool_thumb.webp differ diff --git a/apps/editor/public/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2 b/apps/editor/public/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2 new file mode 100644 index 000000000..35f62d16d Binary files /dev/null and b/apps/editor/public/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/wool_boucle/wool_boucle_normal_512.ktx2 b/apps/editor/public/material/fabric/wool_boucle/wool_boucle_normal_512.ktx2 new file mode 100644 index 000000000..33ef3adea Binary files /dev/null and b/apps/editor/public/material/fabric/wool_boucle/wool_boucle_normal_512.ktx2 differ diff --git a/apps/editor/public/material/fabric/wool_boucle/wool_boucle_thumb.webp b/apps/editor/public/material/fabric/wool_boucle/wool_boucle_thumb.webp new file mode 100644 index 000000000..e627e1592 Binary files /dev/null and b/apps/editor/public/material/fabric/wool_boucle/wool_boucle_thumb.webp differ diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_height.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_height.jpg deleted file mode 100644 index 31361d4ec..000000000 Binary files a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_height.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_height.jpg deleted file mode 100644 index af3013c84..000000000 Binary files a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_height.jpg b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_height.jpg deleted file mode 100644 index 513b22a40..000000000 Binary files a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basepattern.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basepattern.jpg deleted file mode 100644 index 0627ce841..000000000 Binary files a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basepattern.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_height.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_height.jpg deleted file mode 100644 index c6e63434d..000000000 Binary files a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_height.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_height.jpg deleted file mode 100644 index 5b8406efb..000000000 Binary files a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/garage_panel/garage_panel_displacement.jpg b/apps/editor/public/material/flooring/garage_panel/garage_panel_displacement.jpg deleted file mode 100644 index fc9d3e7a7..000000000 Binary files a/apps/editor/public/material/flooring/garage_panel/garage_panel_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_displacement.jpg b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_displacement.jpg deleted file mode 100644 index b4749037e..000000000 Binary files a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_displacement.jpg b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_displacement.jpg deleted file mode 100644 index e42a3f55e..000000000 Binary files a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_height.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_height.jpg deleted file mode 100644 index 08bafef0b..000000000 Binary files a/apps/editor/public/material/flooring/ground_earth/ground_earth_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_height.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_height.jpg deleted file mode 100644 index 5b8406efb..000000000 Binary files a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_displacement.jpg b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_displacement.jpg deleted file mode 100644 index 432e2f132..000000000 Binary files a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/statuaretto/statuaretto_displacement.jpg b/apps/editor/public/material/flooring/statuaretto/statuaretto_displacement.jpg deleted file mode 100644 index 3893d305e..000000000 Binary files a/apps/editor/public/material/flooring/statuaretto/statuaretto_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/stone_wall/stone_wall_displacement.webp b/apps/editor/public/material/flooring/stone_wall/stone_wall_displacement.webp deleted file mode 100644 index 1403b9470..000000000 Binary files a/apps/editor/public/material/flooring/stone_wall/stone_wall_displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_height.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_height.jpg deleted file mode 100644 index ab9c5ecb5..000000000 Binary files a/apps/editor/public/material/flooring/terrazzo/terrazzo_height.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_mask.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_mask.jpg deleted file mode 100644 index 84cf3c55d..000000000 Binary files a/apps/editor/public/material/flooring/terrazzo/terrazzo_mask.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_height.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_height.webp deleted file mode 100644 index 1db0c9c78..000000000 Binary files a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_height.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_height.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_height.webp deleted file mode 100644 index 88de29084..000000000 Binary files a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_height.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_jointmask.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_jointmask.webp deleted file mode 100644 index 2ec006d14..000000000 Binary files a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_jointmask.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_height.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_height.webp deleted file mode 100644 index 382425ca7..000000000 Binary files a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_height.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_mortar.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_mortar.webp deleted file mode 100644 index 2f53f2646..000000000 Binary files a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_mortar.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_height.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_height.webp deleted file mode 100644 index 089cf12ec..000000000 Binary files a/apps/editor/public/material/flooring/tile_stone/tile_stone_height.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_mortar.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_mortar.webp deleted file mode 100644 index daac16ad3..000000000 Binary files a/apps/editor/public/material/flooring/tile_stone/tile_stone_mortar.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_height.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_height.webp deleted file mode 100644 index 570e9fa7e..000000000 Binary files a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_height.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_mortar.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_mortar.webp deleted file mode 100644 index 16e077780..000000000 Binary files a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_mortar.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_displacement.jpg b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_displacement.jpg deleted file mode 100644 index d3fcf9979..000000000 Binary files a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_displacement.jpg b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_displacement.jpg deleted file mode 100644 index a485c13f5..000000000 Binary files a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_displacement.jpg and /dev/null differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-displacement.webp b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-displacement.webp deleted file mode 100644 index 9a1e5d59b..000000000 Binary files a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-displacement.webp b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-displacement.webp deleted file mode 100644 index e122f42c1..000000000 Binary files a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_height.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_height.webp deleted file mode 100644 index 583886a9e..000000000 Binary files a/apps/editor/public/material/flooring/woodparquet/woodparquet_height.webp and /dev/null differ diff --git a/apps/editor/public/material/leather/black_leather/black_leather_basecolor_512.ktx2 b/apps/editor/public/material/leather/black_leather/black_leather_basecolor_512.ktx2 new file mode 100644 index 000000000..a8967cc48 Binary files /dev/null and b/apps/editor/public/material/leather/black_leather/black_leather_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/leather/black_leather/black_leather_normal_512.ktx2 b/apps/editor/public/material/leather/black_leather/black_leather_normal_512.ktx2 new file mode 100644 index 000000000..6259e966c Binary files /dev/null and b/apps/editor/public/material/leather/black_leather/black_leather_normal_512.ktx2 differ diff --git a/apps/editor/public/material/leather/black_leather/black_leather_roughness_512.ktx2 b/apps/editor/public/material/leather/black_leather/black_leather_roughness_512.ktx2 new file mode 100644 index 000000000..b331e3cd2 Binary files /dev/null and b/apps/editor/public/material/leather/black_leather/black_leather_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/leather/black_leather/black_leather_thumb.webp b/apps/editor/public/material/leather/black_leather/black_leather_thumb.webp new file mode 100644 index 000000000..a7bf577bd Binary files /dev/null and b/apps/editor/public/material/leather/black_leather/black_leather_thumb.webp differ diff --git a/apps/editor/public/material/leather/calf_leather/calf_leather_ao_512.ktx2 b/apps/editor/public/material/leather/calf_leather/calf_leather_ao_512.ktx2 new file mode 100644 index 000000000..3e3dfc3c9 Binary files /dev/null and b/apps/editor/public/material/leather/calf_leather/calf_leather_ao_512.ktx2 differ diff --git a/apps/editor/public/material/leather/calf_leather/calf_leather_basecolor_512.ktx2 b/apps/editor/public/material/leather/calf_leather/calf_leather_basecolor_512.ktx2 new file mode 100644 index 000000000..4bec7cd97 Binary files /dev/null and b/apps/editor/public/material/leather/calf_leather/calf_leather_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/leather/calf_leather/calf_leather_normal_512.ktx2 b/apps/editor/public/material/leather/calf_leather/calf_leather_normal_512.ktx2 new file mode 100644 index 000000000..9a0684760 Binary files /dev/null and b/apps/editor/public/material/leather/calf_leather/calf_leather_normal_512.ktx2 differ diff --git a/apps/editor/public/material/leather/calf_leather/calf_leather_roughness_512.ktx2 b/apps/editor/public/material/leather/calf_leather/calf_leather_roughness_512.ktx2 new file mode 100644 index 000000000..aa9ac1b64 Binary files /dev/null and b/apps/editor/public/material/leather/calf_leather/calf_leather_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/leather/calf_leather/calf_leather_thumb.webp b/apps/editor/public/material/leather/calf_leather/calf_leather_thumb.webp new file mode 100644 index 000000000..6399dd031 Binary files /dev/null and b/apps/editor/public/material/leather/calf_leather/calf_leather_thumb.webp differ diff --git a/apps/editor/public/material/metal/copper_metal/copper_metal_basecolor_512.ktx2 b/apps/editor/public/material/metal/copper_metal/copper_metal_basecolor_512.ktx2 new file mode 100644 index 000000000..7e060ffd3 Binary files /dev/null and b/apps/editor/public/material/metal/copper_metal/copper_metal_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/metal/copper_metal/copper_metal_metallic_512.ktx2 b/apps/editor/public/material/metal/copper_metal/copper_metal_metallic_512.ktx2 new file mode 100644 index 000000000..a7c1c2a94 Binary files /dev/null and b/apps/editor/public/material/metal/copper_metal/copper_metal_metallic_512.ktx2 differ diff --git a/apps/editor/public/material/metal/copper_metal/copper_metal_normal_512.ktx2 b/apps/editor/public/material/metal/copper_metal/copper_metal_normal_512.ktx2 new file mode 100644 index 000000000..67d77b82b Binary files /dev/null and b/apps/editor/public/material/metal/copper_metal/copper_metal_normal_512.ktx2 differ diff --git a/apps/editor/public/material/metal/copper_metal/copper_metal_roughness_512.ktx2 b/apps/editor/public/material/metal/copper_metal/copper_metal_roughness_512.ktx2 new file mode 100644 index 000000000..67755a5f3 Binary files /dev/null and b/apps/editor/public/material/metal/copper_metal/copper_metal_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/metal/copper_metal/copper_metal_thumb.webp b/apps/editor/public/material/metal/copper_metal/copper_metal_thumb.webp new file mode 100644 index 000000000..bc4922b71 Binary files /dev/null and b/apps/editor/public/material/metal/copper_metal/copper_metal_thumb.webp differ diff --git a/apps/editor/public/material/metal/polished_metal/polished_metal_ao_512.ktx2 b/apps/editor/public/material/metal/polished_metal/polished_metal_ao_512.ktx2 new file mode 100644 index 000000000..9912baca1 Binary files /dev/null and b/apps/editor/public/material/metal/polished_metal/polished_metal_ao_512.ktx2 differ diff --git a/apps/editor/public/material/metal/polished_metal/polished_metal_basecolor_512.ktx2 b/apps/editor/public/material/metal/polished_metal/polished_metal_basecolor_512.ktx2 new file mode 100644 index 000000000..765e15653 Binary files /dev/null and b/apps/editor/public/material/metal/polished_metal/polished_metal_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/metal/polished_metal/polished_metal_metallic_512.ktx2 b/apps/editor/public/material/metal/polished_metal/polished_metal_metallic_512.ktx2 new file mode 100644 index 000000000..ffb512d8a Binary files /dev/null and b/apps/editor/public/material/metal/polished_metal/polished_metal_metallic_512.ktx2 differ diff --git a/apps/editor/public/material/metal/polished_metal/polished_metal_normal_512.ktx2 b/apps/editor/public/material/metal/polished_metal/polished_metal_normal_512.ktx2 new file mode 100644 index 000000000..c36745405 Binary files /dev/null and b/apps/editor/public/material/metal/polished_metal/polished_metal_normal_512.ktx2 differ diff --git a/apps/editor/public/material/metal/polished_metal/polished_metal_roughness_512.ktx2 b/apps/editor/public/material/metal/polished_metal/polished_metal_roughness_512.ktx2 new file mode 100644 index 000000000..2d672c6ed Binary files /dev/null and b/apps/editor/public/material/metal/polished_metal/polished_metal_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/metal/polished_metal/polished_metal_thumb.webp b/apps/editor/public/material/metal/polished_metal/polished_metal_thumb.webp new file mode 100644 index 000000000..74f5b3019 Binary files /dev/null and b/apps/editor/public/material/metal/polished_metal/polished_metal_thumb.webp differ diff --git a/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_basecolor_512.ktx2 b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_basecolor_512.ktx2 new file mode 100644 index 000000000..97c228d0c Binary files /dev/null and b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_normal_512.ktx2 b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_normal_512.ktx2 new file mode 100644 index 000000000..07952d2a9 Binary files /dev/null and b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_normal_512.ktx2 differ diff --git a/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_roughness_512.ktx2 b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_roughness_512.ktx2 new file mode 100644 index 000000000..c8dd1f5fb Binary files /dev/null and b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp new file mode 100644 index 000000000..2222f9b72 Binary files /dev/null and b/apps/editor/public/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_height.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_height.webp deleted file mode 100644 index ba99a8131..000000000 Binary files a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_height.webp and /dev/null differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_height.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_height.webp deleted file mode 100644 index ba7101326..000000000 Binary files a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_height.webp and /dev/null differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_height.webp b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_height.webp deleted file mode 100644 index 276cb84e6..000000000 Binary files a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_height.webp and /dev/null differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_height.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_height.webp deleted file mode 100644 index 08c2881d1..000000000 Binary files a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/finewood_27/finewood_27_height.webp b/apps/editor/public/material/wood/finewood_27/finewood_27_height.webp deleted file mode 100644 index 16bfb343b..000000000 Binary files a/apps/editor/public/material/wood/finewood_27/finewood_27_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/floor_plank_1/floor_plank-displacement.webp b/apps/editor/public/material/wood/floor_plank_1/floor_plank-displacement.webp deleted file mode 100644 index 81ef4e4cb..000000000 Binary files a/apps/editor/public/material/wood/floor_plank_1/floor_plank-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_fine/wood_fine_1-displacement.webp b/apps/editor/public/material/wood/wood_fine/wood_fine_1-displacement.webp deleted file mode 100644 index 70222ead9..000000000 Binary files a/apps/editor/public/material/wood/wood_fine/wood_fine_1-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-displacement.webp b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-displacement.webp deleted file mode 100644 index 310ed25be..000000000 Binary files a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-displacement.webp b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-displacement.webp deleted file mode 100644 index 6c3eb2bd5..000000000 Binary files a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-displacement.webp b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-displacement.webp deleted file mode 100644 index fa2cead69..000000000 Binary files a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-displacement.webp b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-displacement.webp deleted file mode 100644 index a5b4c329c..000000000 Binary files a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-displacement.webp b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-displacement.webp deleted file mode 100644 index 80d03819b..000000000 Binary files a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-displacement.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_height.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_height.webp deleted file mode 100644 index 5f5307ad8..000000000 Binary files a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_height.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_height.webp deleted file mode 100644 index dcffb4c83..000000000 Binary files a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_joint.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_joint.webp deleted file mode 100644 index 5637053d2..000000000 Binary files a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_joint.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_height.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_height.webp deleted file mode 100644 index 39cfe81a2..000000000 Binary files a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Height.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Height.webp deleted file mode 100644 index 5d608e7c2..000000000 Binary files a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_height.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_height.webp deleted file mode 100644 index 4470f3079..000000000 Binary files a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodplank_19/woodplank_19_height.webp b/apps/editor/public/material/wood/woodplank_19/woodplank_19_height.webp deleted file mode 100644 index efcb6e719..000000000 Binary files a/apps/editor/public/material/wood/woodplank_19/woodplank_19_height.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/woodplank_48/woodplank_48_Height.webp b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Height.webp deleted file mode 100644 index da1532d5f..000000000 Binary files a/apps/editor/public/material/wood/woodplank_48/woodplank_48_Height.webp and /dev/null differ diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31e097989..67ffb1518 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,6 +72,12 @@ export { segmentsIntersect, } from './lib/polygon-relations' export { getRenderableSlabPolygon } from './lib/slab-polygon' +export { + deriveSlotId, + isSlotMaterialName, + SLOT_MATERIAL_PREFIX, + slotLabelFromId, +} from './lib/slots' export { type AutoCeilingPlanningContext, type AutoCeilingSyncPlan, @@ -100,12 +106,20 @@ export { getLibraryMaterialIdFromRef, getMaterialPresetByRef, getMaterialsForCategory, + getSceneMaterialIdFromRef, LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, MATERIAL_CATEGORIES, + MATERIAL_SURFACES, type MaterialCatalogItem, type MaterialCategory, + type MaterialRef, + type MaterialSurface, + type ParsedMaterialRef, + parseMaterialRef, + SCENE_MATERIAL_REF_PREFIX, toLibraryMaterialRef, + toSceneMaterialRef, } from './material-library' export type { FloorPlacedFootprint, diff --git a/packages/core/src/lib/slots.ts b/packages/core/src/lib/slots.ts new file mode 100644 index 000000000..3b3db329a --- /dev/null +++ b/packages/core/src/lib/slots.ts @@ -0,0 +1,27 @@ +export const SLOT_MATERIAL_PREFIX = 'slot_' + +/** A glTF material name marks a paintable slot when it starts with `slot_` (case-insensitive). */ +export function isSlotMaterialName(name: string): boolean { + return name.toLowerCase().startsWith(SLOT_MATERIAL_PREFIX) +} + +/** + * Derive the stable slot id from a glTF material name: + * strip the `slot_` prefix (case-insensitive), drop Blender numeric dedupe + * suffixes like `.001`, lowercase the remainder. Returns null when the name + * is not a slot material. Used by BOTH the upload scan (later) and the + * renderer so DB metadata and runtime meshes can never drift. + */ +export function deriveSlotId(materialName: string): string | null { + if (!isSlotMaterialName(materialName)) return null + let rest = materialName.slice(SLOT_MATERIAL_PREFIX.length) + rest = rest.replace(/\.\d+$/, '') + return rest.toLowerCase() +} + +/** slot id -> display label: underscores to spaces, sentence case. e.g. 'bed_frame' -> 'Bed frame'. */ +export function slotLabelFromId(slotId: string): string { + const spaced = slotId.replace(/_/g, ' ').trim() + if (!spaced) return spaced + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index d97f3b606..161b25b6d 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -8,6 +8,11 @@ export type MaterialCatalogItem = { id: string label: string category: MaterialCategory + /** + * Where this finish is appropriate. Absent = universal (e.g. flat colors). + * The paint picker may filter by the slot being painted; v1 shows everything. + */ + surfaces?: MaterialSurface[] description?: string previewThumbnailUrl?: string previewColor?: string @@ -51,14 +56,38 @@ const ROOF_TARGETS: MaterialTarget[] = [ const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling] -export const MATERIAL_CATEGORIES = ['wood', 'flooring', 'roof', 'other'] as const +export const MATERIAL_CATEGORIES = [ + 'colors', + 'wood', + 'stone', + 'brick', + 'tile', + 'concrete', + 'metal', + 'fabric', + 'leather', + 'roofing', + 'ground', + 'glass', +] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] +export const MATERIAL_SURFACES = [ + 'floor', + 'wall', + 'ceiling', + 'roof', + 'furniture', + 'outdoor', +] as const +export type MaterialSurface = (typeof MATERIAL_SURFACES)[number] + export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wood-finewood27', label: 'Finewood 27', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/finewood_27/finewood_27_basecolor.webp', preset: { @@ -96,6 +125,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-floorplank1', label: 'Floor Plank 1', category: 'wood', + surfaces: ['floor'], description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', preset: { @@ -133,6 +163,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-hungarianparquet10', label: 'Hungarian Parquet 10', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', preset: { @@ -170,6 +201,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-hungarianparquet2', label: 'Hungarian Parquet 2', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', preset: { @@ -207,6 +239,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-squareparquet21', label: 'Square Parquet 21', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', @@ -246,6 +279,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-squareparquet23', label: 'Square Parquet 23', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', @@ -286,6 +320,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodfine1', label: 'Wood Fine 1', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', preset: { @@ -323,6 +358,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodfine11', label: 'Wood Fine 11', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', preset: { @@ -360,6 +396,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodfine13', label: 'Wood Fine 13', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', preset: { @@ -397,6 +434,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodfine2', label: 'Wood Fine 2', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', preset: { @@ -434,6 +472,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodfine22', label: 'Wood Fine 22', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', preset: { @@ -471,6 +510,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodfine24', label: 'Wood Fine 24', category: 'wood', + surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', previewThumbnailUrl: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', preset: { @@ -508,6 +548,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodparquet14', label: 'Wood Parquet 14', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp', preset: { @@ -546,6 +587,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodenparquet11', label: 'Wooden Parquet 11', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', preset: { @@ -583,6 +625,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodparquet121', label: 'Wood Parquet 121', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_121/woodparquet_121_basecolor.webp', preset: { @@ -620,6 +663,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodparquet56', label: 'Wood Parquet 56', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_56/woodparquet_56_basecolor.webp', preset: { @@ -658,6 +702,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodparquet65', label: 'Wood Parquet 65', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp', preset: { @@ -696,6 +741,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodparquet99', label: 'Wood Parquet 99', category: 'wood', + surfaces: ['floor'], description: 'Parquet wood finish', previewThumbnailUrl: '/material/wood/woodparquet_99/woodparquet_99_basecolor.webp', preset: { @@ -734,6 +780,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodplank19', label: 'Wood Plank 19', category: 'wood', + surfaces: ['floor', 'wall'], description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/woodplank_19/woodplank_19_basecolor.webp', preset: { @@ -771,6 +818,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wood-woodplank48', label: 'Wood Plank 48', category: 'wood', + surfaces: ['floor', 'wall'], description: 'Wood plank finish', previewThumbnailUrl: '/material/wood/woodplank_48/woodplank_48_BaseColor.webp', preset: { @@ -807,7 +855,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tile85a', label: 'Quarry Tile', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_quarry/tile_quarry_basecolor.webp', preset: { @@ -844,7 +893,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-rusticbrick', label: 'Rustic Brick', - category: 'flooring', + category: 'brick', + surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg', preset: { @@ -882,7 +932,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-agedbrick', label: 'Aged Brick', - category: 'flooring', + category: 'brick', + surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg', preset: { @@ -920,7 +971,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-weatheredbrick', label: 'Weathered Brick', - category: 'flooring', + category: 'brick', + surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', previewThumbnailUrl: '/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg', @@ -958,7 +1010,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-garagedoor', label: 'Garage Panel', - category: 'flooring', + category: 'metal', + surfaces: ['wall', 'furniture'], description: 'Panel finish', previewThumbnailUrl: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', preset: { @@ -995,7 +1048,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-greenlabradorite', label: 'Green Labradorite', - category: 'flooring', + category: 'stone', + surfaces: ['floor', 'wall'], description: 'Stone flooring finish', previewThumbnailUrl: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', preset: { @@ -1032,7 +1086,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-ground13', label: 'Earth Ground', - category: 'flooring', + category: 'ground', + surfaces: ['outdoor', 'floor'], description: 'Ground surface finish', previewThumbnailUrl: '/material/flooring/ground_earth/ground_earth_basecolor.jpg', preset: { @@ -1070,7 +1125,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-pooltiles', label: 'Pool Tiles', - category: 'flooring', + category: 'tile', + surfaces: ['floor', 'outdoor'], description: 'Pool tile finish', previewThumbnailUrl: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', preset: { @@ -1107,7 +1163,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tiles3', label: 'Checker Tiles', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Tile flooring finish', previewThumbnailUrl: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', preset: { @@ -1144,7 +1201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tiles4', label: 'Grid Tiles', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Tile flooring finish', previewThumbnailUrl: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', preset: { @@ -1181,7 +1239,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-wallstone1', label: 'Stone Wall', - category: 'flooring', + category: 'stone', + surfaces: ['wall', 'floor', 'outdoor'], description: 'Stone finish', previewThumbnailUrl: '/material/flooring/stone_wall/stone_wall_diffuse.webp', preset: { @@ -1218,7 +1277,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-woodenceramic3', label: 'Wooden Ceramic 3', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Wood-look ceramic flooring finish', previewThumbnailUrl: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', preset: { @@ -1255,7 +1315,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-ceramic53', label: 'Ceramic Mosaic', - category: 'flooring', + category: 'tile', + surfaces: ['floor', 'wall'], description: 'Ceramic flooring finish', previewThumbnailUrl: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg', preset: { @@ -1293,7 +1354,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-terrazzo19', label: 'Terrazzo', - category: 'flooring', + category: 'stone', + surfaces: ['floor', 'wall'], description: 'Terrazzo flooring finish', previewThumbnailUrl: '/material/flooring/terrazzo/terrazzo_basecolor.jpg', preset: { @@ -1330,7 +1392,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tile79', label: 'Stone Tile', - category: 'flooring', + category: 'stone', + surfaces: ['floor', 'wall'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_stone/tile_stone_basecolor.webp', preset: { @@ -1367,7 +1430,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tile86', label: 'Terracotta Tile', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp', preset: { @@ -1404,7 +1468,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-greenquartzitea', label: 'Green Quartzite A', - category: 'flooring', + category: 'stone', + surfaces: ['floor', 'wall'], description: 'Green quartzite flooring finish', previewThumbnailUrl: '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', @@ -1442,7 +1507,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-darkceramic22', label: 'Dark Ceramic Grunge', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Dark ceramic flooring finish', previewThumbnailUrl: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg', preset: { @@ -1480,7 +1546,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-lightceramic24', label: 'Light Ceramic Grunge', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Light ceramic flooring finish', previewThumbnailUrl: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg', @@ -1519,7 +1586,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-statuarettowhite', label: 'Statuaretto White', - category: 'flooring', + category: 'stone', + surfaces: ['floor', 'wall'], description: 'White marble flooring finish', previewThumbnailUrl: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', preset: { @@ -1556,7 +1624,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tile20', label: 'Mosaic Tile', - category: 'flooring', + category: 'tile', + surfaces: ['floor', 'wall'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp', preset: { @@ -1594,7 +1663,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-tile68', label: 'Pattern Tile', - category: 'flooring', + category: 'tile', + surfaces: ['floor', 'wall'], description: 'Floor tile finish', previewThumbnailUrl: '/material/flooring/tile_pattern/tile_pattern_basecolor.webp', preset: { @@ -1632,7 +1702,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-woodenceramic2', label: 'Wooden Ceramic 2', - category: 'flooring', + category: 'tile', + surfaces: ['floor'], description: 'Wood-look ceramic flooring finish', previewThumbnailUrl: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', preset: { @@ -1669,7 +1740,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'flooring-woodparquet76', label: 'Wood Parquet', - category: 'flooring', + category: 'wood', + surfaces: ['floor'], description: 'Wood parquet flooring finish', previewThumbnailUrl: '/material/flooring/woodparquet/woodparquet_basecolor.webp', preset: { @@ -1707,7 +1779,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'roof-classicshingles', label: 'Classic Shingles', - category: 'roof', + category: 'roofing', + surfaces: ['roof'], description: 'Classic roof shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp', @@ -1748,7 +1821,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'roof-claytiles', label: 'Clay Tiles', - category: 'roof', + category: 'roofing', + surfaces: ['roof'], description: 'Clay roof tile finish', previewThumbnailUrl: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp', preset: { @@ -1786,7 +1860,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'roof-terracottatiles', label: 'Terracotta Tiles', - category: 'roof', + category: 'roofing', + surfaces: ['roof'], description: 'Terracotta roof tile finish', previewThumbnailUrl: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp', @@ -1827,7 +1902,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'roof-weatheredshingles', label: 'Weathered Shingles', - category: 'roof', + category: 'roofing', + surfaces: ['roof'], description: 'Weathered roof shingle finish', previewThumbnailUrl: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp', @@ -1870,7 +1946,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-white', label: 'White', - category: 'other', + category: 'colors', description: 'Clean painted finish', previewColor: '#ffffff', preset: { @@ -1902,7 +1978,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-softwhite', label: 'Soft White', - category: 'other', + category: 'colors', description: 'Warm off-white painted finish', previewColor: '#f2eee6', preset: { @@ -1934,7 +2010,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-cream', label: 'Cream', - category: 'other', + category: 'colors', description: 'Soft cream painted finish', previewColor: '#efe3cc', preset: { @@ -1966,7 +2042,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-beige', label: 'Beige', - category: 'other', + category: 'colors', description: 'Balanced beige painted finish', previewColor: '#d9c7ad', preset: { @@ -1995,10 +2071,42 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, }, + { + id: 'preset-lightgrey', + label: 'Light grey', + category: 'colors', + description: 'Cool light grey painted finish', + previewColor: '#d8d6d1', + preset: { + maps: {}, + mapProperties: { + color: '#d8d6d1', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, { id: 'preset-greige', label: 'Greige', - category: 'other', + category: 'colors', description: 'Neutral greige painted finish', previewColor: '#c8c1b8', preset: { @@ -2028,15 +2136,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-sage', - label: 'Sage', - category: 'other', - description: 'Muted sage painted finish', - previewColor: '#bcc5b2', + id: 'preset-midgrey', + label: 'Mid grey', + category: 'colors', + description: 'Neutral mid grey painted finish', + previewColor: '#8b8a86', preset: { maps: {}, mapProperties: { - color: '#bcc5b2', + color: '#8b8a86', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2060,15 +2168,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-softblue', - label: 'Soft Blue', - category: 'other', - description: 'Muted blue painted finish', - previewColor: '#c7d6e3', + id: 'preset-charcoal', + label: 'Charcoal', + category: 'colors', + description: 'Dark charcoal painted finish', + previewColor: '#4e5257', preset: { maps: {}, mapProperties: { - color: '#c7d6e3', + color: '#4e5257', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2092,15 +2200,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-terracotta', - label: 'Terracotta', - category: 'other', - description: 'Warm terracotta painted finish', - previewColor: '#c86f4c', + id: 'preset-nearblack', + label: 'Near-black', + category: 'colors', + description: 'Soft near-black painted finish', + previewColor: '#232322', preset: { maps: {}, mapProperties: { - color: '#c86f4c', + color: '#232322', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2124,15 +2232,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-dustyrose', - label: 'Dusty Rose', - category: 'other', - description: 'Muted rose painted finish', - previewColor: '#c48a8d', + id: 'preset-blush', + label: 'Blush', + category: 'colors', + description: 'Pale blush painted finish', + previewColor: '#e8c6c0', preset: { maps: {}, mapProperties: { - color: '#c48a8d', + color: '#e8c6c0', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2156,15 +2264,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-olive', - label: 'Olive', - category: 'other', - description: 'Natural olive painted finish', - previewColor: '#8d9368', + id: 'preset-tomato', + label: 'Tomato', + category: 'colors', + description: 'Warm tomato red painted finish', + previewColor: '#c0594f', preset: { maps: {}, mapProperties: { - color: '#8d9368', + color: '#c0594f', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2188,15 +2296,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-forest', - label: 'Forest', - category: 'other', - description: 'Deep forest green painted finish', - previewColor: '#4f6b57', + id: 'preset-brickred', + label: 'Brick red', + category: 'colors', + description: 'Deep brick red painted finish', + previewColor: '#9e3b34', preset: { maps: {}, mapProperties: { - color: '#4f6b57', + color: '#9e3b34', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2220,15 +2328,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-slateblue', - label: 'Slate Blue', - category: 'other', - description: 'Muted slate blue painted finish', - previewColor: '#6f87a4', + id: 'preset-oxblood', + label: 'Oxblood', + category: 'colors', + description: 'Dark oxblood painted finish', + previewColor: '#6f2c2a', preset: { maps: {}, mapProperties: { - color: '#6f87a4', + color: '#6f2c2a', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2252,15 +2360,143 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-navy', - label: 'Navy', - category: 'other', - description: 'Classic navy painted finish', - previewColor: '#2f4865', + id: 'preset-peach', + label: 'Peach', + category: 'colors', + description: 'Soft peach painted finish', + previewColor: '#f0c3a0', preset: { maps: {}, mapProperties: { - color: '#2f4865', + color: '#f0c3a0', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-terracotta', + label: 'Terracotta', + category: 'colors', + description: 'Warm terracotta painted finish', + previewColor: '#c86f4c', + preset: { + maps: {}, + mapProperties: { + color: '#c86f4c', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-burntorange', + label: 'Burnt orange', + category: 'colors', + description: 'Burnt orange painted finish', + previewColor: '#b25a2c', + preset: { + maps: {}, + mapProperties: { + color: '#b25a2c', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-clay', + label: 'Clay', + category: 'colors', + description: 'Deep clay painted finish', + previewColor: '#8f4a2e', + preset: { + maps: {}, + mapProperties: { + color: '#8f4a2e', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-paleyellow', + label: 'Pale yellow', + category: 'colors', + description: 'Pale yellow painted finish', + previewColor: '#f2e3b3', + preset: { + maps: {}, + mapProperties: { + color: '#f2e3b3', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2286,7 +2522,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-mustard', label: 'Mustard', - category: 'other', + category: 'colors', description: 'Warm mustard painted finish', previewColor: '#c8a449', preset: { @@ -2316,15 +2552,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-charcoal', - label: 'Charcoal', - category: 'other', - description: 'Dark charcoal painted finish', - previewColor: '#4e5257', + id: 'preset-ochre', + label: 'Ochre', + category: 'colors', + description: 'Warm ochre painted finish', + previewColor: '#b8852f', preset: { maps: {}, mapProperties: { - color: '#4e5257', + color: '#b8852f', roughness: 0.9, metalness: 0, repeatX: 1, @@ -2348,17 +2584,17 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-metal', - label: 'Metal', - category: 'other', - description: 'Brushed metal finish', - previewColor: '#c0c0c0', + id: 'preset-gold', + label: 'Gold', + category: 'colors', + description: 'Deep gold painted finish', + previewColor: '#9c7320', preset: { maps: {}, mapProperties: { - color: '#c7ccd2', - roughness: 0.26, - metalness: 0.82, + color: '#9c7320', + roughness: 0.9, + metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, @@ -2380,17 +2616,17 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-glass', - label: 'Glass', - category: 'other', - description: 'Light glass finish', - previewColor: '#87ceeb', + id: 'preset-mint', + label: 'Mint', + category: 'colors', + description: 'Soft mint painted finish', + previewColor: '#cfe0cf', preset: { maps: {}, mapProperties: { - color: '#87ceeb', - roughness: 0.1, - metalness: 0.1, + color: '#cfe0cf', + roughness: 0.9, + metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, @@ -2400,38 +2636,1577 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleY: 1, emissiveIntensity: 1, displacementScale: 0.02, - transparent: true, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, - side: 2, - opacity: 0.3, + side: 0, + opacity: 1, lightMapIntensity: 1, }, }, }, -] - -export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.category === category) -} - -export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { - if (!id) return undefined - return MATERIAL_CATALOG.find((item) => item.id === id) -} - -export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' - -export function toLibraryMaterialRef(id: string) { - return `${LIBRARY_MATERIAL_REF_PREFIX}${id}` -} - -export function getLibraryMaterialIdFromRef(materialRef?: string | null) { - if (!materialRef) return null - if (!materialRef.startsWith(LIBRARY_MATERIAL_REF_PREFIX)) return null - return materialRef.slice(LIBRARY_MATERIAL_REF_PREFIX.length) + { + id: 'preset-sage', + label: 'Sage', + category: 'colors', + description: 'Muted sage painted finish', + previewColor: '#bcc5b2', + preset: { + maps: {}, + mapProperties: { + color: '#bcc5b2', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-olive', + label: 'Olive', + category: 'colors', + description: 'Natural olive painted finish', + previewColor: '#8d9368', + preset: { + maps: {}, + mapProperties: { + color: '#8d9368', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-forest', + label: 'Forest', + category: 'colors', + description: 'Deep forest green painted finish', + previewColor: '#4f6b57', + preset: { + maps: {}, + mapProperties: { + color: '#4f6b57', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-paleteal', + label: 'Pale teal', + category: 'colors', + description: 'Pale teal painted finish', + previewColor: '#b9d2cf', + preset: { + maps: {}, + mapProperties: { + color: '#b9d2cf', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-teal', + label: 'Teal', + category: 'colors', + description: 'Balanced teal painted finish', + previewColor: '#4f8a86', + preset: { + maps: {}, + mapProperties: { + color: '#4f8a86', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-deepteal', + label: 'Deep teal', + category: 'colors', + description: 'Deep teal painted finish', + previewColor: '#2f5f5c', + preset: { + maps: {}, + mapProperties: { + color: '#2f5f5c', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-powderblue', + label: 'Powder blue', + category: 'colors', + description: 'Powder blue painted finish', + previewColor: '#cddce8', + preset: { + maps: {}, + mapProperties: { + color: '#cddce8', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-softblue', + label: 'Soft Blue', + category: 'colors', + description: 'Muted blue painted finish', + previewColor: '#c7d6e3', + preset: { + maps: {}, + mapProperties: { + color: '#c7d6e3', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-sky', + label: 'Sky', + category: 'colors', + description: 'Sky blue painted finish', + previewColor: '#8fb4d4', + preset: { + maps: {}, + mapProperties: { + color: '#8fb4d4', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-slateblue', + label: 'Slate Blue', + category: 'colors', + description: 'Muted slate blue painted finish', + previewColor: '#6f87a4', + preset: { + maps: {}, + mapProperties: { + color: '#6f87a4', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-royalblue', + label: 'Royal blue', + category: 'colors', + description: 'Royal blue painted finish', + previewColor: '#3a5a9c', + preset: { + maps: {}, + mapProperties: { + color: '#3a5a9c', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-navy', + label: 'Navy', + category: 'colors', + description: 'Classic navy painted finish', + previewColor: '#2f4865', + preset: { + maps: {}, + mapProperties: { + color: '#2f4865', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-lavender', + label: 'Lavender', + category: 'colors', + description: 'Soft lavender painted finish', + previewColor: '#c9c2da', + preset: { + maps: {}, + mapProperties: { + color: '#c9c2da', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-plum', + label: 'Plum', + category: 'colors', + description: 'Muted plum painted finish', + previewColor: '#6d4a63', + preset: { + maps: {}, + mapProperties: { + color: '#6d4a63', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-aubergine', + label: 'Aubergine', + category: 'colors', + description: 'Deep aubergine painted finish', + previewColor: '#594354', + preset: { + maps: {}, + mapProperties: { + color: '#594354', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-petal', + label: 'Petal', + category: 'colors', + description: 'Pale petal pink painted finish', + previewColor: '#f0d3da', + preset: { + maps: {}, + mapProperties: { + color: '#f0d3da', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-rose', + label: 'Rose', + category: 'colors', + description: 'Muted rose painted finish', + previewColor: '#cf8fa6', + preset: { + maps: {}, + mapProperties: { + color: '#cf8fa6', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-dustyrose', + label: 'Dusty Rose', + category: 'colors', + description: 'Muted rose painted finish', + previewColor: '#c48a8d', + preset: { + maps: {}, + mapProperties: { + color: '#c48a8d', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-berry', + label: 'Berry', + category: 'colors', + description: 'Deep berry painted finish', + previewColor: '#8f4a5a', + preset: { + maps: {}, + mapProperties: { + color: '#8f4a5a', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-sand', + label: 'Sand', + category: 'colors', + description: 'Warm sand painted finish', + previewColor: '#ddccae', + preset: { + maps: {}, + mapProperties: { + color: '#ddccae', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-tan', + label: 'Tan', + category: 'colors', + description: 'Natural tan painted finish', + previewColor: '#c4a87f', + preset: { + maps: {}, + mapProperties: { + color: '#c4a87f', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-taupe', + label: 'Taupe', + category: 'colors', + description: 'Earthy taupe painted finish', + previewColor: '#8f7a64', + preset: { + maps: {}, + mapProperties: { + color: '#8f7a64', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-espresso', + label: 'Espresso', + category: 'colors', + description: 'Dark espresso painted finish', + previewColor: '#4a382c', + preset: { + maps: {}, + mapProperties: { + color: '#4a382c', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-metal', + label: 'Metal', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Brushed metal finish', + previewColor: '#c0c0c0', + preset: { + maps: {}, + mapProperties: { + color: '#c7ccd2', + roughness: 0.26, + metalness: 0.82, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-glass', + label: 'Glass', + category: 'glass', + description: 'Light glass finish', + previewColor: '#87ceeb', + preset: { + maps: {}, + mapProperties: { + color: '#87ceeb', + roughness: 0.1, + metalness: 0.1, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: true, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + // FrontSide — DoubleSide on a NodeMaterial poisons the WebGPU MRT scene + // pass (window/door glass relies on this). It's the only glass we use. + side: 0, + opacity: 0.3, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'fabric-linen', + label: 'Linen', + category: 'fabric', + surfaces: ['furniture', 'wall'], + description: 'Natural linen weave', + previewThumbnailUrl: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp', + preset: { + maps: { + albedoMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2', + normalMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_normal_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.85, + metalness: 0, + repeatX: 2, + repeatY: 2, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'fabric-cotton', + label: 'Cotton', + category: 'fabric', + surfaces: ['furniture', 'wall'], + description: 'Plain cotton weave', + previewThumbnailUrl: '/material/fabric/blue_cotton/blue_cotton_thumb.webp', + preset: { + maps: { + albedoMap: '/material/fabric/blue_cotton/blue_cotton_basecolor_512.ktx2', + normalMap: '/material/fabric/blue_cotton/blue_cotton_normal_512.ktx2', + roughnessMap: '/material/fabric/blue_cotton/blue_cotton_roughness_512.ktx2', + aoMap: '/material/fabric/blue_cotton/blue_cotton_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.85, + metalness: 0, + repeatX: 2, + repeatY: 2, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'fabric-velvet', + label: 'Velvet', + category: 'fabric', + surfaces: ['furniture'], + description: 'Velvet with a soft sheen', + previewThumbnailUrl: '/material/fabric/red_velvet/red_velvet_thumb.webp', + preset: { + maps: { + albedoMap: '/material/fabric/red_velvet/red_velvet_basecolor_512.ktx2', + normalMap: '/material/fabric/red_velvet/red_velvet_normal_512.ktx2', + roughnessMap: '/material/fabric/red_velvet/red_velvet_roughness_512.ktx2', + aoMap: '/material/fabric/red_velvet/red_velvet_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.6, + metalness: 0, + repeatX: 2.5, + repeatY: 2.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'fabric-wool', + label: 'Wool', + category: 'fabric', + surfaces: ['furniture'], + description: 'Matte wool felt', + previewThumbnailUrl: '/material/fabric/white_wool/white_wool_thumb.webp', + preset: { + maps: { + albedoMap: '/material/fabric/white_wool/white_wool_basecolor_512.ktx2', + normalMap: '/material/fabric/white_wool/white_wool_normal_512.ktx2', + roughnessMap: '/material/fabric/white_wool/white_wool_roughness_512.ktx2', + aoMap: '/material/fabric/white_wool/white_wool_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + repeatX: 2.5, + repeatY: 2.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'fabric-suede', + label: 'Suede', + category: 'fabric', + surfaces: ['furniture'], + description: 'Matte suede nap', + previewThumbnailUrl: '/material/fabric/suede/suede_thumb.webp', + preset: { + maps: { + albedoMap: '/material/fabric/suede/suede_basecolor_512.ktx2', + normalMap: '/material/fabric/suede/suede_normal_512.ktx2', + roughnessMap: '/material/fabric/suede/suede_roughness_512.ktx2', + aoMap: '/material/fabric/suede/suede_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + repeatX: 2, + repeatY: 2, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'fabric-boucle', + label: 'Bouclé', + category: 'fabric', + surfaces: ['furniture'], + description: 'Looped bouclé upholstery', + previewThumbnailUrl: '/material/fabric/wool_boucle/wool_boucle_thumb.webp', + preset: { + maps: { + albedoMap: '/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2', + normalMap: '/material/fabric/wool_boucle/wool_boucle_normal_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + repeatX: 3.3, + repeatY: 3.3, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'leather-black', + label: 'Black Leather', + category: 'leather', + surfaces: ['furniture'], + description: 'Smooth black leather', + previewThumbnailUrl: '/material/leather/black_leather/black_leather_thumb.webp', + preset: { + maps: { + albedoMap: '/material/leather/black_leather/black_leather_basecolor_512.ktx2', + normalMap: '/material/leather/black_leather/black_leather_normal_512.ktx2', + roughnessMap: '/material/leather/black_leather/black_leather_roughness_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1.67, + repeatY: 1.67, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'leather-calf', + label: 'Calf Leather', + category: 'leather', + surfaces: ['furniture'], + description: 'Pebbled calf leather', + previewThumbnailUrl: '/material/leather/calf_leather/calf_leather_thumb.webp', + preset: { + maps: { + albedoMap: '/material/leather/calf_leather/calf_leather_basecolor_512.ktx2', + normalMap: '/material/leather/calf_leather/calf_leather_normal_512.ktx2', + roughnessMap: '/material/leather/calf_leather/calf_leather_roughness_512.ktx2', + aoMap: '/material/leather/calf_leather/calf_leather_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 2, + repeatY: 2, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'concrete-plaster', + label: 'Painted Plaster', + category: 'concrete', + surfaces: ['wall', 'ceiling'], + description: 'Smooth painted plaster wall', + previewThumbnailUrl: '/material/concrete/plaster_painted/plaster_painted_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/plaster_painted/plaster_painted_basecolor_512.ktx2', + normalMap: '/material/concrete/plaster_painted/plaster_painted_normal_512.ktx2', + roughnessMap: '/material/concrete/plaster_painted/plaster_painted_roughness_512.ktx2', + aoMap: '/material/concrete/plaster_painted/plaster_painted_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.85, + metalness: 0, + repeatX: 0.67, + repeatY: 0.67, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'concrete-polished', + label: 'Polished Concrete', + category: 'concrete', + surfaces: ['floor', 'wall'], + description: 'Polished concrete floor', + previewThumbnailUrl: '/material/concrete/concrete_polished/concrete_polished_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2', + normalMap: '/material/concrete/concrete_polished/concrete_polished_normal_512.ktx2', + roughnessMap: '/material/concrete/concrete_polished/concrete_polished_roughness_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'concrete-raw', + label: 'Raw Concrete', + category: 'concrete', + surfaces: ['wall', 'floor', 'outdoor'], + description: 'Board-formed raw concrete', + previewThumbnailUrl: '/material/concrete/concrete_raw/concrete_raw_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/concrete_raw/concrete_raw_basecolor_512.ktx2', + normalMap: '/material/concrete/concrete_raw/concrete_raw_normal_512.ktx2', + roughnessMap: '/material/concrete/concrete_raw/concrete_raw_roughness_512.ktx2', + aoMap: '/material/concrete/concrete_raw/concrete_raw_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.8, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'concrete-plate', + label: 'Concrete Plate', + category: 'concrete', + surfaces: ['wall', 'floor'], + description: 'Smooth cast concrete plate', + previewThumbnailUrl: '/material/concrete/concrete_plate/concrete_plate_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2', + normalMap: '/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2', + roughnessMap: '/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2', + aoMap: '/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.8, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'concrete-stucco', + label: 'White Stucco', + category: 'concrete', + surfaces: ['wall', 'outdoor'], + description: 'Exterior stucco render', + previewThumbnailUrl: '/material/concrete/white_stucco/white_stucco_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/white_stucco/white_stucco_basecolor_512.ktx2', + normalMap: '/material/concrete/white_stucco/white_stucco_normal_512.ktx2', + roughnessMap: '/material/concrete/white_stucco/white_stucco_roughness_512.ktx2', + aoMap: '/material/concrete/white_stucco/white_stucco_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'concrete-drywall', + label: 'Prepared Drywall', + category: 'concrete', + surfaces: ['wall', 'ceiling'], + description: 'Smooth prepared drywall ready for paint', + previewThumbnailUrl: '/material/concrete/prepared_drywall/prepared_drywall_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2', + normalMap: '/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2', + roughnessMap: '/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2', + aoMap: '/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'metal-copper', + label: 'Copper', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Warm copper', + previewThumbnailUrl: '/material/metal/copper_metal/copper_metal_thumb.webp', + preset: { + maps: { + albedoMap: '/material/metal/copper_metal/copper_metal_basecolor_512.ktx2', + normalMap: '/material/metal/copper_metal/copper_metal_normal_512.ktx2', + roughnessMap: '/material/metal/copper_metal/copper_metal_roughness_512.ktx2', + metalnessMap: '/material/metal/copper_metal/copper_metal_metallic_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.4, + metalness: 0.6, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'metal-polished', + label: 'Polished Metal', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished stainless steel', + previewThumbnailUrl: '/material/metal/polished_metal/polished_metal_thumb.webp', + preset: { + maps: { + albedoMap: '/material/metal/polished_metal/polished_metal_basecolor_512.ktx2', + normalMap: '/material/metal/polished_metal/polished_metal_normal_512.ktx2', + roughnessMap: '/material/metal/polished_metal/polished_metal_roughness_512.ktx2', + aoMap: '/material/metal/polished_metal/polished_metal_ao_512.ktx2', + metalnessMap: '/material/metal/polished_metal/polished_metal_metallic_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.3, + metalness: 0.6, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'metal-steel', + label: 'Brushed Steel', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Brushed stainless steel', + previewThumbnailUrl: + '/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp', + preset: { + maps: { + albedoMap: + '/material/metal/stainless_steel_brushed/stainless_steel_brushed_basecolor_512.ktx2', + normalMap: + '/material/metal/stainless_steel_brushed/stainless_steel_brushed_normal_512.ktx2', + roughnessMap: + '/material/metal/stainless_steel_brushed/stainless_steel_brushed_roughness_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0.6, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + // Parameter-only metal (no texture maps) — a worked example of a non-PBR + // catalog finish driven purely by three.js material settings. + id: 'metal-brass', + label: 'Brass', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished brass (flat metal, no maps)', + previewColor: '#b08d57', + preset: { + maps: {}, + mapProperties: { + color: '#b08d57', + roughness: 0.35, + metalness: 0.9, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + // Parameter-only chrome (no texture maps) — moderate metalness so it reads + // as bright metal under direct/ambient light without needing an env map. + id: 'metal-chrome', + label: 'Chrome', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished chrome (flat metal, no maps)', + previewColor: '#c8ccce', + preset: { + maps: {}, + mapProperties: { + color: '#c8ccce', + roughness: 0.2, + metalness: 0.6, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, +] + +export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { + return MATERIAL_CATALOG.filter((item) => item.category === category) +} + +export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { + if (!id) return undefined + return MATERIAL_CATALOG.find((item) => item.id === id) +} + +export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' +export const SCENE_MATERIAL_REF_PREFIX = 'scene:' + +export function toLibraryMaterialRef(id: string) { + return `${LIBRARY_MATERIAL_REF_PREFIX}${id}` +} + +export function toSceneMaterialRef(id: string) { + return `${SCENE_MATERIAL_REF_PREFIX}${id}` +} + +export function getLibraryMaterialIdFromRef(materialRef?: string | null) { + if (!materialRef) return null + if (!materialRef.startsWith(LIBRARY_MATERIAL_REF_PREFIX)) return null + return materialRef.slice(LIBRARY_MATERIAL_REF_PREFIX.length) +} + +export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null { + if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null + return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length) +} + +export type MaterialRef = string + +export type ParsedMaterialRef = { kind: 'library'; id: string } | { kind: 'scene'; id: string } + +export function parseMaterialRef(ref?: string | null): ParsedMaterialRef | null { + const lib = getLibraryMaterialIdFromRef(ref) + if (lib) return { kind: 'library', id: lib } + const scene = getSceneMaterialIdFromRef(ref) + if (scene) return { kind: 'scene', id: scene } + return null } export function getMaterialPresetByRef(materialRef?: string | null): MaterialPresetPayload | null { diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index e8055706b..37d4ac5e5 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -106,6 +106,7 @@ export type { ScalableConfig, SceneApi, SelectableConfig, + SlotDeclaration, SnapPointKind, SnappableConfig, SnapServicesLike, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 1afd7d41e..c12bbdfae 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,7 +1,8 @@ import type { ComponentType } from 'react' -import type { BufferGeometry, Object3D } from 'three' +import type { BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' import type { MaterialSchema } from '../schema/material' +import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import type { HandleList } from './handles' import type { CloneNodesIntoOptions, Subtree } from './subtree' @@ -43,6 +44,15 @@ export type GeometryContext = { * has cheap access to siblings through `ctx.siblings`). */ levelData?: unknown + /** + * The scene's shared material library (`useScene.materials`), passed so a + * pure geometry builder can resolve `scene:` slot refs without importing + * `useScene`. Populated by `` for every `def.geometry` call; + * undefined for `def.floorplan`. `library:` refs resolve against the + * static catalog and need no store, so builders only consult this for + * `scene:` refs. + */ + materials?: Record /** * Optional view state — only populated for `def.floorplan` builders. The * 2D floor-plan layer surfaces selection / hover here so kinds can vary @@ -1124,6 +1134,16 @@ export type Capabilities = { */ ceilingCut?: CeilingCutCapability paint?: PaintCapability + /** + * Declares the kind's paintable slots — the `{ slotId, label, default }` + * contract shared by items (scanned from the GLB) and procedural kinds + * (declared here). Procedural generators tag their emitted geometry with + * `userData.slotId` and resolve each slot's material from + * `node.slots[slotId]` → this declaration's `default` → role colour. The + * declaration is a function of the node because a kind's slot set can depend + * on its parameters (a shelf has a `back` slot only when it has a back). + */ + slots?: (node: AnyNode) => SlotDeclaration[] /** * Kind is placed by clicking on a wall (door, window). When set, the * floor-plan layer lets wall background clicks pass through during @@ -1215,6 +1235,19 @@ export type Capabilities = { * the `selectedMaterialTarget` round-trip, the paint-mode toolbar. * Kinds with no paint behaviour omit `paint`. */ +/** + * One paintable slot a kind exposes. `slotId` is the stable key written into + * `node.slots`; `label` is the human name (sentence case). `default` is the + * slot's fallback appearance when no override is set — either a `MaterialRef` + * (`library:` / `scene:`) or a `#rrggbb` colour. Mirrors the shape + * items derive from their GLB material names. + */ +export type SlotDeclaration = { + slotId: string + label: string + default?: string +} + export type PaintCapability = { /** * Resolve which logical surface the user clicked. Returns `null` @@ -1227,6 +1260,14 @@ export type PaintCapability = { * `role`. Returned partial is merged into the node by the editor. */ buildPatch: (args: PaintPatchArgs) => Partial + /** + * Optional: fully own the click-commit instead of the default + * `updateNode(node.id, buildPatch(...))`. Kinds whose commit has a side + * effect (items create a scene material for one-off colours, then store a + * `scene:` ref) implement this; kinds that just patch the node omit it. + * Must perform its mutations as a single undo step. + */ + commit?: (args: PaintPatchArgs) => void /** * Apply a preview to the kind's registered mesh subtree at * `role`. The kind builds whatever preview material(s) it needs @@ -1270,6 +1311,16 @@ export type PaintResolveArgs = { localPosition?: readonly [number, number, number] /** Optional: name of the three.js object that received the hit. Stair uses this. */ hitObjectName?: string + /** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */ + hitObject?: Object3D + /** + * Optional: the pointer's world ray, so a kind can re-raycast its OWN subtree + * to pick the precise sub-mesh under the cursor — independent of what the + * shared scene raycast hit first. Door/window use this: their opening proxy + * (a proud invisible cutout) wins the scene raycast over the wall in front of + * the recessed door body, then they re-raycast their parts to find the slot. + */ + ray?: Ray } export type PaintPatchArgs = { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index aae58c9c5..e9d270dcc 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -163,10 +163,12 @@ export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' export { getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, + WALL_SLOT_DEFAULT, WallNode, } from './nodes/wall' export { WindowNode, WindowType } from './nodes/window' export { ZoneNode } from './nodes/zone' +export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' export type { AnyNodeId, AnyNodeType } from './types' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 724bb0fde..b94aaaa2b 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -11,6 +11,10 @@ export const CeilingNode = BaseNode.extend({ children: z.array(ItemNode.shape.id).default([]), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index 78a521264..de4116045 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -159,6 +159,9 @@ export const ColumnNode = BaseNode.extend({ bracePlateEnabled: z.boolean().default(true), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), }).describe(dedent` Column node - used to represent structural or decorative pillars/columns. - style: visual approach such as plain, lathe-turned, carved, or cluster diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index fd26ad687..26b4170e0 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -41,6 +41,10 @@ export const DoorNode = BaseNode.extend({ id: objectId('door'), type: nodeType('door'), material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `panel` (the + // door body), `glass`. Value = a `MaterialRef` (`library:` / `scene:`). + // Absent = the body/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/elevator.ts b/packages/core/src/schema/nodes/elevator.ts index 75639387e..d1c97b491 100644 --- a/packages/core/src/schema/nodes/elevator.ts +++ b/packages/core/src/schema/nodes/elevator.ts @@ -16,6 +16,9 @@ export const ElevatorNode = BaseNode.extend({ type: nodeType('elevator'), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around the Y axis in radians. rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 408757173..1818d0075 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -11,6 +11,9 @@ export const FenceNode = BaseNode.extend({ type: nodeType('fence'), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), height: z.number().default(1.8), diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index c03aa4b8d..f8732482b 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -146,6 +146,11 @@ export const ItemNode = BaseNode.extend({ // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), + // Per-slot material overrides. Key = slot id (see deriveSlotId), value = a + // MaterialRef string ('library:' or 'scene:'). Absent = authored / + // registry default. A dangling ref renders the default (never blocks). + slots: z.record(z.string(), z.string()).optional(), + asset: assetSchema, }).describe(dedent`Item node - used to represent a item in the building - position: position in level coordinate system (or parent coordinate system if attached) diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index 58d7af5e5..3e9b74b2a 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -86,6 +86,13 @@ export const ShelfNode = BaseNode.extend({ // unchanged once `'shelf'` is added to `MaterialTarget`. material: MaterialSchema.optional(), materialPreset: z.string().optional(), + + // Per-slot material overrides, mirroring `ItemNode.slots`. Key = slot id + // (`shelves` / `frame` / `back`, see `shelfSlots`), value = a `MaterialRef` + // string (`library:` or `scene:`). Absent slot = fall back to the + // legacy whole-shelf `material` / `materialPreset`, then the registry slot + // default. A dangling ref renders the default (never blocks). + slots: z.record(z.string(), z.string()).optional(), }) export type ShelfNode = z.infer diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 5232eaaf8..fe3c47c1d 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -9,6 +9,10 @@ export const SlabNode = BaseNode.extend({ type: nodeType('slab'), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index a0c68fe18..abc1fe54a 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -31,6 +31,9 @@ export const StairNode = BaseNode.extend({ treadMaterialPreset: z.string().optional(), sideMaterial: MaterialSchema.optional(), sideMaterialPreset: z.string().optional(), + // Unified paint-slot refs (`scene:`/`library:` MaterialRef per slot id), + // matching the slot model items/slab/shelf use. Absent = declared default. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ebce121c2..a93d22bd7 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -20,6 +20,13 @@ export const WallNode = BaseNode.extend({ interiorMaterialPreset: z.string().optional(), exteriorMaterial: MaterialSchema.optional(), exteriorMaterialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `SlabNode.slots`. Key = slot id (`interior` / `exterior`), value = a + // `MaterialRef` (`library:` / `scene:`). Absent = the declared slot + // default (`WALL_SLOT_DEFAULT`). The legacy `*Material*` fields above are + // read only by the load migration that moves them into `slots`; delete them + // in a follow-up once migrated scenes are the norm. + slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), @@ -46,6 +53,16 @@ export type WallNode = z.infer export type WallSurfaceSide = 'interior' | 'exterior' +// Declared default appearance for an unpainted wall face in colored mode — +// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the +// slot declaration (nodes) and the material resolver (viewer) share one value. +// May be a `#rrggbb` colour or a `library:` ref. Textures-off still +// collapses to the themed wall role (the escape hatch). +export const WALL_SLOT_DEFAULT: Record = { + interior: 'library:concrete-drywall', + exterior: 'library:concrete-drywall', +} + export type WallSurfaceMaterialSpec = { material?: z.infer materialPreset?: string diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index c5a1f5f01..08c5ce4ef 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -21,6 +21,10 @@ export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `frame`, + // `glass`. Value = a `MaterialRef` (`library:` / `scene:`). Absent = + // the frame/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/scene-material.ts b/packages/core/src/schema/scene-material.ts new file mode 100644 index 000000000..4b1bbd3f6 --- /dev/null +++ b/packages/core/src/schema/scene-material.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { generateId } from './base' +import { MaterialSchema } from './material' + +export type SceneMaterialId = `mat_${string}` +export const generateSceneMaterialId = (): SceneMaterialId => generateId('mat') + +export const SceneMaterial = z.object({ + id: z.string(), + name: z.string(), + material: MaterialSchema, +}) +export type SceneMaterial = z.infer diff --git a/packages/core/src/store/use-scene-wall-slot-migration.test.ts b/packages/core/src/store/use-scene-wall-slot-migration.test.ts new file mode 100644 index 000000000..ef1e90af8 --- /dev/null +++ b/packages/core/src/store/use-scene-wall-slot-migration.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +type WallNode = Extract + +function sceneWithWall(wall: Record): Record { + return { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + }, + wall_test: { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + ...wall, + }, + } as unknown as Record +} + +function sceneWithNode(node: Record): Record { + return { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['node_test'], + level: 0, + }, + node_test: { + object: 'node', + id: 'node_test', + visible: true, + metadata: {}, + parentId: 'level_test', + ...node, + }, + } as unknown as Record +} + +function resetScene() { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + } as never) + useScene.temporal.getState().clear() +} + +describe('wall surface-material → slots migration', () => { + beforeEach(() => { + resetScene() + }) + + test('moves legacy library presets into slots and clears the inline fields', () => { + useScene.getState().setScene( + sceneWithWall({ + interiorMaterialPreset: 'library:concrete-plate', + exteriorMaterialPreset: 'library:wood-woodplank48', + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots).toEqual({ + interior: 'library:concrete-plate', + exterior: 'library:wood-woodplank48', + }) + expect(wall.interiorMaterialPreset).toBeUndefined() + expect(wall.exteriorMaterialPreset).toBeUndefined() + }) + + test('mints a scene material for an inline legacy material and references it', () => { + useScene.getState().setScene( + sceneWithWall({ + interiorMaterial: { properties: { color: '#abcdef' } }, + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + const interiorRef = wall.slots?.interior + expect(interiorRef?.startsWith('scene:')).toBe(true) + expect(wall.interiorMaterial).toBeUndefined() + + const materials = useScene.getState().materials + const id = interiorRef!.slice('scene:'.length) + expect(materials[id as keyof typeof materials]?.material).toEqual({ + properties: { color: '#abcdef' }, + } as never) + }) + + test('legacy catch-all material applies to both faces; identical inline customs share one scene material', () => { + useScene.getState().setScene( + sceneWithWall({ + material: { properties: { color: '#112233' } }, + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots?.interior).toBeDefined() + expect(wall.slots?.interior).toBe(wall.slots?.exterior as string) + expect(wall.material).toBeUndefined() + // One minted datablock shared across both faces. + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + }) + + test('leaves an already slot-modelled wall untouched and mints nothing for unpainted walls', () => { + useScene + .getState() + .setScene(sceneWithWall({ slots: { interior: 'library:concrete-drywall' } }), [ + 'site_test', + ] as never) + + const migratedWall = useScene.getState().nodes.wall_test as WallNode + expect(migratedWall.slots).toEqual({ interior: 'library:concrete-drywall' }) + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + + useScene.getState().setScene(sceneWithWall({}), ['site_test'] as never) + const plainWall = useScene.getState().nodes.wall_test as WallNode + expect(plainWall.slots).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + }) +}) + +type SlottedNode = AnyNode & { slots?: Record; material?: unknown } + +describe('procedural kind surface-material → slots migration', () => { + beforeEach(resetScene) + + test('slab: legacy preset → slots.surface, legacy cleared', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + materialPreset: 'library:flooring-tiles3', + }), + ['site_test'] as never, + ) + + const slab = (useScene.getState().nodes as Record).node_test! + expect(slab.slots).toEqual({ surface: 'library:flooring-tiles3' }) + expect((slab as { materialPreset?: unknown }).materialPreset).toBeUndefined() + }) + + test('ceiling: inline custom material mints a scene material on slots.surface', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'ceiling', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + material: { properties: { color: '#ddeeff' } }, + }), + ['site_test'] as never, + ) + + const ceiling = (useScene.getState().nodes as Record).node_test! + const ref = ceiling.slots?.surface + expect(ref?.startsWith('scene:')).toBe(true) + expect(ceiling.material).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + }) + + test('fence: legacy preset fans out to every slot id (one shared ref)', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'fence', + start: [0, 0], + end: [4, 0], + materialPreset: 'library:wood-woodplank48', + }), + ['site_test'] as never, + ) + + const fence = (useScene.getState().nodes as Record).node_test! + expect(fence.slots).toEqual({ + posts: 'library:wood-woodplank48', + infill: 'library:wood-woodplank48', + base: 'library:wood-woodplank48', + rail: 'library:wood-woodplank48', + }) + }) + + test('stair: per-role legacy fields map tread→treads, side→body, railing→railing', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'stair', + treadMaterialPreset: 'library:wood-woodplank48', + sideMaterialPreset: 'library:concrete-plate', + railingMaterialPreset: 'library:metal-chrome', + }), + ['site_test'] as never, + ) + + const stair = (useScene.getState().nodes as Record).node_test! + expect(stair.slots?.treads).toBe('library:wood-woodplank48') + expect(stair.slots?.body).toBe('library:concrete-plate') + expect(stair.slots?.railing).toBe('library:metal-chrome') + expect((stair as { treadMaterialPreset?: unknown }).treadMaterialPreset).toBeUndefined() + }) + + test('unpainted procedural node mints nothing and stays slot-less', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + }), + ['site_test'] as never, + ) + + const slab = (useScene.getState().nodes as Record).node_test! + expect(slab.slots).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index a3e7a009e..c7dcbb5e3 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -3,6 +3,7 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { parseMaterialRef, toSceneMaterialRef } from '../material-library' import { nodeRegistry } from '../registry/registry' import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' @@ -18,8 +19,17 @@ import { import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls' import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' import { SiteNode } from '../schema/nodes/site' -import { StairNode as StairNodeSchema } from '../schema/nodes/stair' +import { + getEffectiveStairSurfaceMaterial, + StairNode as StairNodeSchema, +} from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' +import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall' +import { + generateSceneMaterialId, + type SceneMaterial, + type SceneMaterialId, +} from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' @@ -231,47 +241,162 @@ function migrateElevatorParent( } } -function migrateWallSurfaceMaterials(node: Record) { - const hasInterior = - node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' - const hasExterior = - node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string' - const legacyFinish = { - material: node.material, - materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, +// Reuse an already-minted scene material for an identical inline legacy +// material so a whole building painted one custom colour collapses to one +// shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match). +function findMintedSceneMaterialRef( + material: unknown, + mintedMaterials: Record, +): string | undefined { + const target = JSON.stringify(material) + for (const sceneMaterial of Object.values(mintedMaterials)) { + if (JSON.stringify(sceneMaterial.material) === target) { + return toSceneMaterialRef(sceneMaterial.id) + } } + return undefined +} - if (!(hasInterior || hasExterior)) { - if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { - return node +// Turn a legacy surface spec (`{ material, materialPreset }`) into a +// `MaterialRef`: a preset that's already a `library:`/`scene:` ref is used +// as-is; an inline material mints (or reuses) a scene material. Returns +// undefined when the spec carries no material. Shared by every legacy→slots +// migration below. +function legacySpecToMaterialRef( + spec: { material?: unknown; materialPreset?: unknown }, + mintedMaterials: Record, +): string | undefined { + if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) { + return spec.materialPreset + } + if (spec.material !== undefined) { + const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials) + if (existing) return existing + const id = generateSceneMaterialId() + mintedMaterials[id] = { + id, + name: `Material ${Object.keys(mintedMaterials).length + 1}`, + material: spec.material as SceneMaterial['material'], } + return toSceneMaterialRef(id) + } + return undefined +} - return { - ...node, - interiorMaterial: legacyFinish.material, - interiorMaterialPreset: legacyFinish.materialPreset, - exteriorMaterial: legacyFinish.material, - exteriorMaterialPreset: legacyFinish.materialPreset, - } +// Move the retired inline `material*` / `interiorMaterial*` / `exteriorMaterial*` +// fields onto the unified `node.slots` model (interior / exterior → a +// `library:`/`scene:` ref), minting scene materials for inline customs into +// `mintedMaterials` (merged into the scene material map by the caller). Already +// slot-modelled walls and walls with no legacy material are left untouched. +function migrateWallSurfaceMaterials( + node: Record, + mintedMaterials: Record, +) { + if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) { + return node } - if (!hasInterior) { - return { - ...node, - interiorMaterial: node.exteriorMaterial, - interiorMaterialPreset: node.exteriorMaterialPreset, - } + const slots: Record = { ...(node.slots ?? {}) } + for (const side of ['interior', 'exterior'] as WallSurfaceSide[]) { + const spec = getEffectiveWallSurfaceMaterial( + node as Parameters[0], + side, + ) + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[side] = ref } - if (!hasExterior) { - return { - ...node, - exteriorMaterial: node.interiorMaterial, - exteriorMaterialPreset: node.interiorMaterialPreset, - } + if (Object.keys(slots).length === 0) { + return node } - return node + return { + ...node, + slots, + material: undefined, + materialPreset: undefined, + interiorMaterial: undefined, + interiorMaterialPreset: undefined, + exteriorMaterial: undefined, + exteriorMaterialPreset: undefined, + } +} + +// Move a kind's single legacy `material` / `materialPreset` onto its declared +// slots. A pre-slot-model node painted one material rendered that material on +// every part (each slot resolves `node.slots[slot]` → legacy → default), so the +// migration writes the same ref to every slot id the kind can expose — unused +// conditional slots are harmless. Already slot-modelled or unpainted nodes are +// left untouched. Mirrors `migrateWallSurfaceMaterials` for single-surface and +// whole-object kinds (slab, ceiling, fence, column, shelf). +function migrateSingleMaterialSlots( + node: Record, + slotIds: readonly string[], + mintedMaterials: Record, +) { + if (node.slots && Object.keys(node.slots).length > 0) { + return node + } + + const ref = legacySpecToMaterialRef( + { material: node.material, materialPreset: node.materialPreset }, + mintedMaterials, + ) + if (!ref) { + return node + } + + const slots: Record = {} + for (const slotId of slotIds) slots[slotId] = ref + + return { ...node, slots, material: undefined, materialPreset: undefined } +} + +// Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` / +// `railingMaterial*`) plus a catch-all. Map each to its slot via the same +// fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`): +// tread→treads, side→body, railing→railing. Runs after +// `migrateStairSurfaceMaterials` has normalised the legacy fields. +function migrateStairSurfaceSlots( + node: Record, + mintedMaterials: Record, +) { + if (node.slots && Object.keys(node.slots).length > 0) { + return node + } + + const roleToSlot = [ + ['tread', 'treads'], + ['side', 'body'], + ['railing', 'railing'], + ] as const + + const slots: Record = {} + for (const [role, slotId] of roleToSlot) { + const spec = getEffectiveStairSurfaceMaterial( + node as Parameters[0], + role, + ) + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[slotId] = ref + } + + if (Object.keys(slots).length === 0) { + return node + } + + return { + ...node, + slots, + material: undefined, + materialPreset: undefined, + treadMaterial: undefined, + treadMaterialPreset: undefined, + sideMaterial: undefined, + sideMaterialPreset: undefined, + railingMaterial: undefined, + railingMaterialPreset: undefined, + } } function migrateStairSurfaceMaterials(node: Record) { @@ -414,11 +539,17 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } -function migrateNodes(nodes: Record): Record { +function migrateNodes(nodes: Record): { + nodes: Record + mintedMaterials: Record +} { // Repair pre-existing corruption (null children, zero-length walls) before // any per-type migration runs, so already-saved scenes load cleanly. const { nodes: healed } = healSceneNodes(nodes) const patchedNodes = { ...healed } as Record + // Scene materials minted while moving legacy wall fields onto `node.slots`; + // merged into the scene material map by the caller (`setScene`). + const mintedMaterials: Record = {} for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { @@ -505,6 +636,7 @@ function migrateNodes(nodes: Record): Record { if (normalized) { patchedNodes[id] = normalized } + patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials) } if (node.type === 'stair-segment') { @@ -515,7 +647,27 @@ function migrateNodes(nodes: Record): Record { } if (node.type === 'wall') { - patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) + patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) + } + + if (node.type === 'slab' || node.type === 'ceiling') { + patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) + } + + if (node.type === 'fence') { + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['posts', 'infill', 'base', 'rail'], + mintedMaterials, + ) + } + + if (node.type === 'column') { + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['shaft', 'base', 'capital', 'frame'], + mintedMaterials, + ) } if (node.type === 'shelf') { @@ -523,6 +675,11 @@ function migrateNodes(nodes: Record): Record { if (normalized) { patchedNodes[id] = normalized } + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['shelves', 'frame', 'back'], + mintedMaterials, + ) } if (node.type === 'elevator') { @@ -622,7 +779,7 @@ function migrateNodes(nodes: Record): Record { } } } - return patchedNodes as Record + return { nodes: patchedNodes as Record, mintedMaterials } } function getNodeChildIds(node: AnyNode): AnyNodeId[] { @@ -698,6 +855,7 @@ export type SceneState = { // 4. Relational metadata — not nodes collections: Record + materials: Record // 5. Read-only lock — when true all create/update/delete operations are no-ops readOnly: boolean @@ -707,7 +865,14 @@ export type SceneState = { loadScene: () => void clearScene: () => void unloadScene: () => void - setScene: (nodes: Record, rootNodeIds: AnyNodeId[]) => void + setScene: ( + nodes: Record, + rootNodeIds: AnyNodeId[], + extra?: { + collections?: Record + materials?: Record + }, + ) => void markDirty: (id: AnyNodeId) => void clearDirty: (id: AnyNodeId) => void @@ -732,12 +897,19 @@ export type SceneState = { updateCollection: (id: CollectionId, data: Partial>) => void addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void + + // Scene material actions + addSceneMaterial: (material: SceneMaterial) => void + updateSceneMaterial: (id: SceneMaterialId, data: Partial>) => void + removeSceneMaterial: (id: SceneMaterialId) => void } // type PartializedStoreState = Pick; type UseSceneStore = UseBoundStore> & { - temporal: StoreApi>> + temporal: StoreApi< + TemporalState> + > } const useScene: UseSceneStore = create()( @@ -754,6 +926,7 @@ const useScene: UseSceneStore = create()( // 4. Collections collections: {} as Record, + materials: {} as Record, // 5. Read-only lock readOnly: false, @@ -765,6 +938,7 @@ const useScene: UseSceneStore = create()( rootNodeIds: [], dirtyNodes: new Set(), collections: {}, + materials: {}, }) }, @@ -773,9 +947,13 @@ const useScene: UseSceneStore = create()( get().loadScene() // Default scene }, - setScene: (nodes, rootNodeIds) => { + setScene: (nodes, rootNodeIds, extra) => { // Apply backward compatibility migrations - const patchedNodes = migrateNodes(nodes) + const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes) + // Scene materials minted by the wall legacy→slots migration join the + // loaded palette (existing refs win on id collision — there are none, + // ids are freshly generated). + const materials = { ...mintedMaterials, ...(extra?.materials ?? {}) } // Remove orphans: nodes whose parentId points to a non-existent node const cleanedNodes = { ...patchedNodes } @@ -796,7 +974,8 @@ const useScene: UseSceneStore = create()( nodes: cleanedNodes, rootNodeIds, dirtyNodes: new Set(), - collections: {}, + collections: extra?.collections ?? {}, + materials, }) const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) @@ -813,7 +992,8 @@ const useScene: UseSceneStore = create()( nodes: cleanedNodes, rootNodeIds: normalizedRootNodeIds, dirtyNodes: new Set(), - collections: {}, + collections: extra?.collections ?? {}, + materials, }) // Mark all nodes as dirty to trigger re-validation Object.values(cleanedNodes).forEach((node) => { @@ -973,11 +1153,38 @@ const useScene: UseSceneStore = create()( return { collections: nextCollections, nodes: nextNodes } }) }, + + // --- SCENE MATERIALS --- + + addSceneMaterial: (material) => { + if (get().readOnly) return + set((state) => ({ + materials: { ...state.materials, [material.id]: material }, + })) + }, + + updateSceneMaterial: (id, data) => { + if (get().readOnly) return + set((state) => { + const material = state.materials[id] + if (!material) return state + return { materials: { ...state.materials, [id]: { ...material, ...data } } } + }) + }, + + removeSceneMaterial: (id) => { + if (get().readOnly) return + set((state) => { + const materials = { ...state.materials } + delete materials[id] + return { materials } + }) + }, }), { partialize: (state) => { - const { nodes, rootNodeIds, collections } = state - return { nodes, rootNodeIds, collections } + const { nodes, rootNodeIds, collections, materials } = state + return { nodes, rootNodeIds, collections, materials } }, limit: 50, // Limit to last 50 actions }, diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 3c8b1d590..d8b27dec4 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -7,7 +7,13 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { + type HoverStyles, + InteractiveSystem, + SceneEnvironment, + useViewer, + Viewer, +} from '@pascal-app/viewer' import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' @@ -608,6 +614,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode return ( <> + {!(isFirstPersonMode || isStudioMode || isCaptureMode) && } {!noEditing && } {!noEditing && } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 71d32281c..d1a42ee6d 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -906,6 +906,8 @@ export const SelectionManager = () => { normal: event.normal, localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, + hitObject: getEventObject(event), + ray: event.nativeEvent.ray, }) const compatible = role !== null && paintEnabled return { @@ -915,15 +917,22 @@ export const SelectionManager = () => { apply: compatible && role ? () => { - useScene.getState().updateNode( - node.id as AnyNodeId, - paintCap.buildPatch({ - node, - role, - material: paintSpec.material, - materialPreset: paintSpec.materialPreset, - }) as Partial, - ) + const args = { + node, + role, + material: paintSpec.material, + materialPreset: paintSpec.materialPreset, + } + if (paintCap.commit) { + paintCap.commit(args) + } else { + useScene + .getState() + .updateNode( + node.id as AnyNodeId, + paintCap.buildPatch(args) as Partial, + ) + } } : null, preview: @@ -1051,13 +1060,7 @@ export const SelectionManager = () => { // before any of the legacy roof / stair / single-surface arms // below run. - if ( - node.type === 'fence' || - node.type === 'column' || - node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'shelf' - ) { + if (node.type === 'fence' || node.type === 'column' || node.type === 'shelf') { const compatible = paintEnabled return { @@ -1086,7 +1089,7 @@ export const SelectionManager = () => { } } - const disabledNodeTypes = ['item', 'window', 'door', 'zone'] + const disabledNodeTypes = ['zone'] if (disabledNodeTypes.includes(node.type)) { return { key: `${node.type}:${node.id}:unsupported`, @@ -1187,6 +1190,11 @@ export const SelectionManager = () => { for (const type of subscribedKinds) { emitter.on(`${type}:enter` as any, onEnter as any) + // Re-evaluate on move so the hover preview tracks the cursor across a + // kind's sub-parts (door/window panel↔frame↔glass↔hardware, wall + // interior↔exterior) — not just on the initial enter. onEnter is + // idempotent (no-ops when the resolved part is unchanged). + emitter.on(`${type}:move` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:click` as any, onClick as any) } @@ -1194,6 +1202,7 @@ export const SelectionManager = () => { return () => { for (const type of subscribedKinds) { emitter.off(`${type}:enter` as any, onEnter as any) + emitter.off(`${type}:move` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:click` as any, onClick as any) } @@ -1553,6 +1562,8 @@ export const SelectionManager = () => { normal: event.normal, localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, + hitObject: getEventObject(event), + ray: event.nativeEvent.ray, }) if (role) { setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole) @@ -1940,7 +1951,8 @@ const SelectionStateSync = () => { const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId] if ( !selectedNode || - (selectedNode.type !== 'wall' && + (!nodeRegistry.get(selectedNode.type)?.capabilities?.paint && + selectedNode.type !== 'wall' && selectedNode.type !== 'fence' && selectedNode.type !== 'slab' && selectedNode.type !== 'ceiling' && diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx index eead7524f..d2fd2d244 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -1,9 +1,15 @@ 'use client' -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + generateSceneMaterialId, + type SceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Eraser, RotateCcw } from 'lucide-react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { buildResetSurfaceMaterialUpdates, resolvePaintTargetFromSelection, @@ -11,6 +17,8 @@ import { import useEditor from './../../../store/use-editor' import { Button } from '../primitives/button' import { MaterialPicker } from './material-picker' +import { PanelSection } from './panel-section' +import { SceneMaterialList } from './scene-material-list' /** * Material picker for paint mode. Embedders render this wherever paint controls @@ -25,8 +33,11 @@ export function MaterialPaintPanel() { const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget) const paintEraser = useEditor((state) => state.paintEraser) const setPaintEraser = useEditor((state) => state.setPaintEraser) + // Id of a just-created scene material whose inline editor should open on mount. + const [autoEditMaterialId, setAutoEditMaterialId] = useState(null) const selectedIds = useViewer((state) => state.selection.selectedIds) const nodes = useScene((state) => state.nodes) + const materialCount = useScene((state) => Object.keys(state.materials).length) const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null const canResetSelection = @@ -70,7 +81,18 @@ export function MaterialPaintPanel() { { - setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) + // Custom-create: pre-create a scene material and select it as the + // brush via a `scene:` ref so painting stores the ref and edits to + // it propagate everywhere. The user edits it inline in the scene- + // material list below (auto-opened) — no separate right-side pane. + const id = generateSceneMaterialId() + const count = Object.keys(useScene.getState().materials).length + useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material }) + setActivePaintMaterial({ + materialPreset: toSceneMaterialRef(id), + sourceTarget: activePaintTarget, + }) + setAutoEditMaterialId(id) }} onSelectMaterialPreset={(materialPreset) => { setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) @@ -78,6 +100,11 @@ export function MaterialPaintPanel() { selectedMaterialPreset={activePaintMaterial?.materialPreset} value={activePaintMaterial?.material} /> + {materialCount > 0 ? ( + + + + ) : null} ) } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index da35979ec..e0b5469f9 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -9,8 +9,8 @@ import { type MaterialTarget, toLibraryMaterialRef, } from '@pascal-app/core' -import { useEffect, useRef, useState } from 'react' -import useEditor from '../../../store/use-editor' +import { useEffect, useState } from 'react' +import { triggerSFX } from '../../../lib/sfx-bus' type MaterialPickerProps = { value?: MaterialSchema @@ -23,7 +23,6 @@ type MaterialPickerProps = { } function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { - if (category === 'roof') return 'Roofing' return category.charAt(0).toUpperCase() + category.slice(1) } @@ -34,16 +33,14 @@ export function MaterialPicker({ onSelectMaterialPreset, disabled = false, }: MaterialPickerProps) { - const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) const [showCustom, setShowCustom] = useState(!!value?.properties) const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( MATERIAL_CATEGORIES[0], ) - const categoryScrollRef = useRef(null) - const catalogItems = - selectedCategory === 'other' - ? getMaterialsForCategory('other') - : getMaterialsForCategory(selectedCategory) + const availableCategories = MATERIAL_CATEGORIES.filter( + (category) => getMaterialsForCategory(category).length > 0, + ) + const catalogItems = getMaterialsForCategory(selectedCategory) useEffect(() => { setShowCustom(!!value?.properties && !selectedMaterialPreset) @@ -51,11 +48,12 @@ export function MaterialPicker({ useEffect(() => { if (!selectedMaterialPreset && value?.properties) { - setSelectedCategory('other') + setSelectedCategory('colors') return } - const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined + const catalogId = + getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined const selectedCatalogEntry = getCatalogMaterialById(catalogId) if (selectedCatalogEntry?.category) { setSelectedCategory(selectedCatalogEntry.category) @@ -64,43 +62,27 @@ export function MaterialPicker({ const selectedCatalogId = selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) + const selectedCatalogMaterialId = getLibraryMaterialIdFromRef(selectedCatalogId) ?? undefined + const selectedCatalogEntry = getCatalogMaterialById(selectedCatalogMaterialId) const handleCatalogSelect = (materialId: string) => { if (disabled) return setShowCustom(false) - setPaintPanelOpen(false) onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) } - useEffect(() => { - const container = categoryScrollRef.current - if (!container) return - - const handleWheel = (event: WheelEvent) => { - const deltaX = event.deltaX - const deltaY = event.deltaY - const nextScrollLeft = container.scrollLeft + deltaX + deltaY - - if (nextScrollLeft === container.scrollLeft) return - - event.preventDefault() - container.scrollLeft = nextScrollLeft - } - - container.addEventListener('wheel', handleWheel, { passive: false }) - return () => { - container.removeEventListener('wheel', handleWheel) - } - }, []) - + // Seed a new custom material from the current/forked colour and hand it to + // the host (MaterialPaintPanel), which pre-creates a scene material the user + // edits inline in the build pane — no separate right-side editor pane. const handleCustomOpen = () => { if (disabled) return - setShowCustom(true) - setPaintPanelOpen(true) + const forkColor = selectedMaterialPreset + ? (selectedCatalogEntry?.previewColor ?? '#ffffff') + : '#ffffff' onChange?.({ preset: 'custom', properties: { - color: value?.properties?.color || '#ffffff', + color: value?.properties?.color || forkColor, roughness: value?.properties?.roughness ?? 0.5, metalness: value?.properties?.metalness ?? 0, opacity: value?.properties?.opacity ?? 1, @@ -114,80 +96,86 @@ export function MaterialPicker({
{(catalogItems.length > 0 || onChange) && (
-
-
- {MATERIAL_CATEGORIES.map((category) => ( - - ))} -
+
+ {availableCategories.map((category) => ( + + ))}
- {catalogItems.map((item) => ( + {catalogItems.map((item) => { + const isSelected = selectedCatalogId === toLibraryMaterialRef(item.id) + return ( - ))} - {selectedCategory === 'other' && onChange ? ( - - ) : null} + + + ) : null}
)} diff --git a/packages/editor/src/components/ui/controls/material-properties-editor.tsx b/packages/editor/src/components/ui/controls/material-properties-editor.tsx new file mode 100644 index 000000000..533400097 --- /dev/null +++ b/packages/editor/src/components/ui/controls/material-properties-editor.tsx @@ -0,0 +1,108 @@ +'use client' + +import type { MaterialProperties, MaterialSchema } from '@pascal-app/core' +import { Input } from '../primitives/input' +import { SliderControl } from './slider-control' + +const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', +} + +export function MaterialPropertiesEditor({ + value, + onChange, +}: { + value: MaterialSchema + onChange: (next: MaterialSchema) => void +}) { + const currentProps = value.properties ?? DEFAULT_MATERIAL_PROPERTIES + + const updateMaterial = ( + updates: Partial, + nextTransparent = currentProps.transparent, + ) => { + onChange({ + ...value, + preset: value.preset ?? 'custom', + properties: { + ...currentProps, + ...updates, + transparent: nextTransparent, + }, + }) + } + + return ( +
+
+ +
+ updateMaterial({ color: e.target.value })} + type="color" + value={currentProps.color} + /> + updateMaterial({ color: e.target.value })} + value={currentProps.color} + /> +
+
+ + updateMaterial({ roughness: value })} + precision={2} + step={0.01} + value={currentProps.roughness} + /> + + updateMaterial({ metalness: value })} + precision={2} + step={0.01} + value={currentProps.metalness} + /> + + updateMaterial({ opacity: value }, value < 1 || currentProps.transparent)} + precision={2} + step={0.01} + value={currentProps.opacity} + /> + +
+ + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/controls/scene-material-list.tsx b/packages/editor/src/components/ui/controls/scene-material-list.tsx new file mode 100644 index 000000000..a300ffc33 --- /dev/null +++ b/packages/editor/src/components/ui/controls/scene-material-list.tsx @@ -0,0 +1,239 @@ +'use client' + +import { + generateSceneMaterialId, + type MaterialSchema, + type SceneMaterial, + type SceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { Copy, Paintbrush, Pencil, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import useEditor from '../../../store/use-editor' +import { Button } from '../primitives/button' +import { Input } from '../primitives/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip' +import { MaterialPropertiesEditor } from './material-properties-editor' + +type SlotRecord = Record + +function getSlotRecord(node: unknown): SlotRecord | null { + if (!node || typeof node !== 'object' || !('slots' in node)) return null + const slots = (node as { slots?: unknown }).slots + if (!slots || typeof slots !== 'object' || Array.isArray(slots)) return null + return slots as SlotRecord +} + +export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId | null }) { + const materials = useScene((state) => state.materials) + const nodes = useScene((state) => state.nodes) + const addSceneMaterial = useScene((state) => state.addSceneMaterial) + const updateSceneMaterial = useScene((state) => state.updateSceneMaterial) + const removeSceneMaterial = useScene((state) => state.removeSceneMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) + + const materialEntries = useMemo( + () => Object.entries(materials) as [SceneMaterialId, SceneMaterial][], + [materials], + ) + + const usageCounts = useMemo(() => { + const counts = new Map() + const refToId = new Map() + + for (const [id] of materialEntries) { + counts.set(id, 0) + refToId.set(toSceneMaterialRef(id), id) + } + + for (const node of Object.values(nodes)) { + const slots = getSlotRecord(node) + if (!slots) continue + + for (const value of Object.values(slots)) { + if (typeof value !== 'string') continue + const materialId = refToId.get(value) + if (!materialId) continue + counts.set(materialId, (counts.get(materialId) ?? 0) + 1) + } + } + + return counts + }, [materialEntries, nodes]) + + return ( +
+ {materialEntries.map(([id, sceneMaterial]) => ( + + ))} +
+ ) +} + +function SceneMaterialRow({ + id, + sceneMaterial, + usageCount, + activePaintTarget, + autoEdit, + addSceneMaterial, + updateSceneMaterial, + removeSceneMaterial, + setActivePaintMaterial, +}: { + id: SceneMaterialId + sceneMaterial: SceneMaterial + usageCount: number + activePaintTarget: ReturnType['activePaintTarget'] + autoEdit: boolean + addSceneMaterial: ReturnType['addSceneMaterial'] + updateSceneMaterial: ReturnType['updateSceneMaterial'] + removeSceneMaterial: ReturnType['removeSceneMaterial'] + setActivePaintMaterial: ReturnType['setActivePaintMaterial'] +}) { + // A freshly-created material (via "+ Custom") mounts with its editor open. + const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit) + const [draftName, setDraftName] = useState(sceneMaterial.name) + const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff' + + useEffect(() => { + setDraftName(sceneMaterial.name) + }, [sceneMaterial.name]) + + const commitName = () => { + const nextName = draftName.trim() + if (!nextName) { + setDraftName(sceneMaterial.name) + return + } + if (nextName !== sceneMaterial.name) { + updateSceneMaterial(id, { name: nextName }) + } + } + + const duplicateMaterial = () => { + addSceneMaterial({ + id: generateSceneMaterialId(), + name: `${sceneMaterial.name} copy`, + material: structuredClone(sceneMaterial.material) as MaterialSchema, + }) + } + + return ( +
+
+ + setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + if (e.key === 'Escape') { + setDraftName(sceneMaterial.name) + e.currentTarget.blur() + } + }} + value={draftName} + /> +
+ +
+ + Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'} + +
+ + + + + Paint with + + + + + + Edit + + + + + + Duplicate + + + + + + Delete + +
+
+ + {isEditingMaterial ? ( +
+ updateSceneMaterial(id, { material })} + value={sceneMaterial.material} + /> +
+ ) : null} +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx deleted file mode 100644 index c11a26a01..000000000 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ /dev/null @@ -1,163 +0,0 @@ -'use client' - -import useEditor from '../../../store/use-editor' -import { PanelSection } from '../controls/panel-section' -import { Input } from '../primitives/input' -import { PanelWrapper } from './panel-wrapper' - -function buildDefaultCustomMaterial() { - return { - preset: 'custom' as const, - properties: { - color: '#ffffff', - roughness: 0.5, - metalness: 0, - opacity: 1, - transparent: false, - side: 'front' as const, - }, - } -} - -export function PaintPanel() { - const activePaintMaterial = useEditor((state) => state.activePaintMaterial) - const activePaintTarget = useEditor((state) => state.activePaintTarget) - const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) - const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) - - const customMaterial = - activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset - ? activePaintMaterial.material - : null - - if (!customMaterial) return null - - const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties - - const updateCustomMaterial = ( - updates: Partial, - nextTransparent = currentProps.transparent, - ) => { - setActivePaintMaterial({ - material: { - preset: 'custom', - properties: { - ...currentProps, - ...updates, - transparent: nextTransparent, - }, - }, - sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, - }) - } - - return ( - setPaintPanelOpen(false)} title="Material" width={320}> - -
-
- -
- updateCustomMaterial({ color: e.target.value })} - type="color" - value={currentProps.color} - /> - updateCustomMaterial({ color: e.target.value })} - value={currentProps.color} - /> -
-
- -
-
- - - {currentProps.roughness.toFixed(2)} - -
- - updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) }) - } - step={0.01} - type="range" - value={currentProps.roughness} - /> -
- -
-
- - - {currentProps.metalness.toFixed(2)} - -
- - updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) }) - } - step={0.01} - type="range" - value={currentProps.metalness} - /> -
- -
-
- - - {currentProps.opacity.toFixed(2)} - -
- { - const opacity = Number.parseFloat(e.target.value) - updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) - }} - step={0.01} - type="range" - value={currentProps.opacity} - /> -
- -
- - -
-
-
-
- ) -} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 7ff454746..d16a6913b 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -29,7 +29,6 @@ import useEditor from '../../../store/use-editor' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' import { getNodeDisplay } from './node-display' -import { PaintPanel } from './paint-panel' import { ParametricInspector } from './parametric-inspector' import { ReferencePanel } from './reference-panel' @@ -174,9 +173,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac const selectedZoneId = useViewer((s) => s.selection.zoneId) const setSelection = useViewer((s) => s.setSelection) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) - const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) - const mode = useEditor((s) => s.mode) - const activePaintMaterial = useEditor((s) => s.activePaintMaterial) // Only subscribe to the *type* of the single-selected node — string primitive // so we don't re-render on unrelated scene mutations. const selectedNodeType = useScene((s) => { @@ -208,15 +204,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac return } - if ( - isPaintPanelOpen && - mode === 'material-paint' && - activePaintMaterial?.material?.properties && - !activePaintMaterial.materialPreset - ) { - return - } - if (selectedZoneId && selectedIds.length === 0) { return ( s.shading) + const textures = useViewer((s) => s.textures) const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0] const ActiveIcon = active.icon return ( @@ -121,6 +130,23 @@ function RenderModeMenu() { ) })} + + {TEXTURE_OPTIONS.map((option) => { + const OptionIcon = option.icon + return ( + useViewer.getState().setTextures(option.id)} + > + +
+ {option.name} + {option.detail} +
+ {textures === option.id ? : null} +
+ ) + })} ) diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index fd4acf3ac..89b82da63 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -61,6 +61,12 @@ export function useAutoSave({ useEffect(() => { let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) let lastNodeCount = Object.keys(useScene.getState().nodes).length + // Collections + scene materials are document-level state that persists with + // the graph but lives outside `nodes`. Track them by reference (zustand + // hands out a new object on every mutation) so a material edit or a + // collection change still triggers a save. + let lastCollectionsRef = useScene.getState().collections + let lastMaterialsRef = useScene.getState().materials async function executeSave() { if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { @@ -69,8 +75,8 @@ export function useAutoSave({ return } - const { nodes, rootNodeIds } = useScene.getState() - const sceneGraph = { nodes, rootNodeIds } as SceneGraph + const { nodes, rootNodeIds, collections, materials } = useScene.getState() + const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph // Guard: refuse to autosave if the scene went from populated to nearly empty. // This catches accidental full deletions before they're persisted. @@ -118,19 +124,29 @@ export function useAutoSave({ const unsubscribe = useScene.subscribe((state) => { if (isLoadingSceneRef.current) { lastNodesSnapshot = JSON.stringify(state.nodes) + lastCollectionsRef = state.collections + lastMaterialsRef = state.materials return } if (isVersionPreviewModeRef.current) { setSaveStatus('paused') lastNodesSnapshot = JSON.stringify(state.nodes) + lastCollectionsRef = state.collections + lastMaterialsRef = state.materials return } const currentNodesSnapshot = JSON.stringify(state.nodes) - if (currentNodesSnapshot === lastNodesSnapshot) return + const changed = + currentNodesSnapshot !== lastNodesSnapshot || + state.collections !== lastCollectionsRef || + state.materials !== lastMaterialsRef + if (!changed) return lastNodesSnapshot = currentNodesSnapshot + lastCollectionsRef = state.collections + lastMaterialsRef = state.materials hasDirtyChangesRef.current = true onDirtyRef.current?.() setSaveStatus('pending') @@ -156,8 +172,8 @@ export function useAutoSave({ function flushOnExit() { if (!hasDirtyChangesRef.current) return hasDirtyChangesRef.current = false - const { nodes, rootNodeIds } = useScene.getState() - const sceneGraph = { nodes, rootNodeIds } as SceneGraph + const { nodes, rootNodeIds, collections, materials } = useScene.getState() + const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph if (onSaveRef.current) { onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {}) } else { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 120915ed5..1ef26d771 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -250,7 +250,6 @@ export { buildRoofSurfaceMaterialPatch, buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, - buildWallSurfaceMaterialPatch, getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index fbd592718..08313bbae 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -13,7 +13,6 @@ import { getEffectiveRoofSurfaceMaterial, getEffectiveSegmentSurfaceMaterial, getEffectiveStairSurfaceMaterial, - getEffectiveWallSurfaceMaterial, getLibraryMaterialIdFromRef, type MaterialSchema, type MaterialTarget, @@ -26,28 +25,29 @@ import { type SlabNode, type StairNode, type StairSurfaceMaterialRole, - type WallNode, type WallSurfaceSide, } from '@pascal-app/core' -export type PaintableMaterialTarget = Extract< - MaterialTarget, - | 'wall' - | 'roof' - | 'stair' - | 'fence' - | 'column' - | 'slab' - | 'ceiling' - | 'shelf' - | 'chimney' - | 'dormer' - | 'box-vent' - | 'ridge-vent' - | 'turbine-vent' - | 'cupola' - | 'eyebrow-vent' -> +export type PaintableMaterialTarget = + | Extract< + MaterialTarget, + | 'wall' + | 'roof' + | 'stair' + | 'fence' + | 'column' + | 'slab' + | 'ceiling' + | 'shelf' + | 'chimney' + | 'dormer' + | 'box-vent' + | 'ridge-vent' + | 'turbine-vent' + | 'cupola' + | 'eyebrow-vent' + > + | 'item' export type SingleSurfaceMaterialRole = 'surface' @@ -76,32 +76,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' } -export function buildWallSurfaceMaterialPatch( - node: WallNode, - targetSide: WallSurfaceSide, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextInterior = - targetSide === 'interior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'interior') - const nextExterior = - targetSide === 'exterior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'exterior') - - return { - interiorMaterial: nextInterior.material, - interiorMaterialPreset: nextInterior.materialPreset, - exteriorMaterial: nextExterior.material, - exteriorMaterialPreset: nextExterior.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - export function buildRoofSurfaceMaterialPatch( node: RoofNode, targetRole: RoofSurfaceMaterialRole, @@ -179,6 +153,7 @@ export function buildResetSurfaceMaterialUpdates( if ( key === 'material' || key === 'materialPreset' || + key === 'slots' || key.endsWith('Material') || key.endsWith('MaterialPreset') ) { @@ -276,6 +251,7 @@ export function resolveActivePaintMaterialFromSelection(params: { | ChimneyMaterialRole | DormerSurfaceMaterialRole | SingleSurfaceMaterialRole + | string } | null }): ActivePaintMaterial | null { const { nodes, selectedId, selectedMaterialTarget } = params @@ -378,8 +354,6 @@ export function resolveActivePaintMaterialFromSelection(params: { if ( (selectedNode.type === 'fence' || selectedNode.type === 'column' || - selectedNode.type === 'slab' || - selectedNode.type === 'ceiling' || selectedNode.type === 'shelf') && selectedMaterialTarget.role === 'surface' ) { @@ -444,6 +418,10 @@ export function resolvePaintTargetFromSelection(params: { return 'shelf' } + if (selectedNode.type === 'item') { + return 'item' + } + if (selectedNode.type === 'chimney') { return 'chimney' } diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 0f8f3552c..02f020c62 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -11,6 +11,10 @@ import useEditor, { export type SceneGraph = { nodes: Record rootNodeIds: string[] + // Document-level scene state that travels with the graph. Optional so older + // payloads (and callers that only build nodes) stay valid. + collections?: Record + materials?: Record } type PersistedSelectionPath = { @@ -374,8 +378,11 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { if (hasUsableSceneGraph(sceneGraph)) { - const { nodes, rootNodeIds } = sceneGraph - useScene.getState().setScene(nodes as any, rootNodeIds as any) + const { nodes, rootNodeIds, collections, materials } = sceneGraph + useScene.getState().setScene(nodes as any, rootNodeIds as any, { + collections: collections as any, + materials: materials as any, + }) } else { useScene.getState().clearScene() } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 19543f62f..64c28b319 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -177,6 +177,7 @@ export type MaterialTargetRole = | ChimneyMaterialRole | DormerSurfaceMaterialRole | SingleSurfaceMaterialRole + | string export type SelectedMaterialTarget = { nodeId: AnyNodeId @@ -325,8 +326,6 @@ type EditorState = { primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot hoveredPaintTarget: PaintableMaterialTarget | null setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void - isPaintPanelOpen: boolean - setPaintPanelOpen: (open: boolean) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void guideUi: Record @@ -891,8 +890,6 @@ const useEditor = create()( set((state) => state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, ), - isPaintPanelOpen: false, - setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), guideUi: {}, diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index e716dc6a2..8183f36a6 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -10,8 +10,10 @@ import { ceilingMoveVertexAffordance, } from './floorplan-affordances' import { ceilingFloorplanMoveTarget } from './floorplan-move' +import { ceilingPaint } from './paint' import { ceilingParametrics } from './parametrics' import { CeilingNode } from './schema' +import { ceilingSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 @@ -102,6 +104,10 @@ export const ceilingDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + // Unified slot model: one paintable underside surface with a declared + // default, painted through the registry `capabilities.paint` dispatch. + slots: () => ceilingSlots(), + paint: ceilingPaint, }, relations: { diff --git a/packages/nodes/src/ceiling/materials.ts b/packages/nodes/src/ceiling/materials.ts new file mode 100644 index 000000000..a6e4e590e --- /dev/null +++ b/packages/nodes/src/ceiling/materials.ts @@ -0,0 +1,80 @@ +import { + getMaterialPresetByRef, + parseMaterialRef, + resolveMaterial, + type SceneMaterial, + type SceneMaterialId, +} from '@pascal-app/core' +import { float, mix, positionWorld, smoothstep } from 'three/tsl' +import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu' + +/** + * Ceiling material builders, shared by the renderer (mesh appearance) and the + * paint capability (hover preview). A ceiling is a flat tinted surface: the + * underside (`bottom`, seen from inside the room, `BackSide`) is opaque, while + * the `top` carries a transparent TSL grid overlay used while placing / + * selecting ceiling-hosted items. Both derive from a single colour, so slot + * painting resolves a colour and rebuilds these — it never applies a PBR map. + */ + +const gridScale = 5 +const gridX = positionWorld.x.mul(gridScale).fract() +const gridY = positionWorld.z.mul(gridScale).fract() +const lineWidth = 0.05 +const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) +const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) +const gridPattern = lineX.max(lineY) +const gridOpacity = mix(float(0.2), float(0.6), gridPattern) + +export type CeilingMaterials = { + topMaterial: MeshBasicNodeMaterial + bottomMaterial: MeshBasicNodeMaterial +} + +function createCeilingMaterials(color = '#999999'): CeilingMaterials { + const topMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthWrite: false, + side: FrontSide, + }) + topMaterial.opacityNode = gridOpacity + + const bottomMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + side: BackSide, + }) + + return { topMaterial, bottomMaterial } +} + +const ceilingMaterialCache = new Map() + +export function getCeilingMaterials(color = '#999999'): CeilingMaterials { + const cached = ceilingMaterialCache.get(color) + if (cached) return cached + const materials = createCeilingMaterials(color) + ceilingMaterialCache.set(color, materials) + return materials +} + +/** + * Resolve a slot `MaterialRef` to a flat colour for the ceiling surface. + * `library:` refs use the catalog preset's base colour; `scene:` refs use the + * stored material's colour. Returns null for a dangling / unparseable ref so + * the caller falls back to its default. + */ +export function ceilingColorFromRef( + ref: string | undefined, + sceneMaterials: Record | undefined, +): string | null { + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties.color ?? null + } + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return resolveMaterial(sceneMaterial.material).color ?? null +} diff --git a/packages/nodes/src/ceiling/paint.ts b/packages/nodes/src/ceiling/paint.ts new file mode 100644 index 000000000..3efce6994 --- /dev/null +++ b/packages/nodes/src/ceiling/paint.ts @@ -0,0 +1,42 @@ +import { + type AnyNode, + type CeilingNode, + getMaterialPresetByRef, + resolveMaterial, +} from '@pascal-app/core' +import type { Mesh } from 'three' +import { createSlotPaintCapability } from '../shared/slot-paint' +import { getCeilingMaterials } from './materials' + +/** + * Ceiling paint on the unified slot model. A ceiling has one paintable surface, + * so every hit resolves to `surface`; commit writes `node.slots.surface`. The + * preview swaps the registered underside mesh to the ceiling's own flat-tinted + * material (built `BackSide`, the way it renders), so the hover preview matches + * the committed result — a generic PBR preview would be invisible from below. + */ +export const ceilingPaint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: ({ material, materialPreset, root }) => { + const color = materialPreset + ? (getMaterialPresetByRef(materialPreset)?.mapProperties.color ?? null) + : material + ? (resolveMaterial(material).color ?? null) + : null + if (!color) return () => {} + const mesh = root as Mesh + if (!mesh.isMesh) return null + const previous = mesh.material + mesh.material = getCeilingMaterials(color).bottomMaterial + return () => { + mesh.material = previous + } + }, + legacyEffective: (node: AnyNode) => { + const ceiling = node as CeilingNode + if (ceiling.materialPreset || ceiling.material) { + return { material: ceiling.material, materialPreset: ceiling.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 263c4fd81..9f49086c8 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -15,53 +15,15 @@ import { useViewer, } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { float, mix, positionWorld, smoothstep } from 'three/tsl' -import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' +import { BackSide, type Mesh } from 'three/webgpu' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { ceilingColorFromRef, getCeilingMaterials } from './materials' +import { CEILING_SLOT_DEFAULT_COLOR } from './slots' function createEmptyGeometry() { return createPlaceholderGeometry() } -const gridScale = 5 -const gridX = positionWorld.x.mul(gridScale).fract() -const gridY = positionWorld.z.mul(gridScale).fract() -const lineWidth = 0.05 -const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) -const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) -const gridPattern = lineX.max(lineY) -const gridOpacity = mix(float(0.2), float(0.6), gridPattern) - -function createCeilingMaterials(color = '#999999') { - const topMaterial = new MeshBasicNodeMaterial({ - color, - transparent: true, - depthWrite: false, - side: FrontSide, - }) - topMaterial.opacityNode = gridOpacity - - const bottomMaterial = new MeshBasicNodeMaterial({ - color, - transparent: true, - side: BackSide, - }) - - return { topMaterial, bottomMaterial } -} - -const ceilingMaterialCache = new Map>() - -function getCeilingMaterials(color = '#999999') { - const cacheKey = color - const cached = ceilingMaterialCache.get(cacheKey) - if (cached) return cached - - const materials = createCeilingMaterials(color) - ceilingMaterialCache.set(cacheKey, materials) - return materials -} - export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) const placeholderGeometry = useMemo(createEmptyGeometry, []) @@ -80,6 +42,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // Subscribe to the scene-material library so editing a `scene:` material the + // ceiling slot references re-tints it live. + const sceneMaterials = useScene((s) => s.materials) const liveTransform = useLiveTransforms((s) => s.get(node.id)) const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) const position: [number, number, number] = [ @@ -97,18 +62,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ) const materials = useMemo(() => { - // Untextured ceilings (and everything in textures-off mode) take the themed - // 'ceiling' role colour; only an explicit preset/material keeps a texture. - const hasExplicit = Boolean(node.materialPreset || node.material) - if (!textures || !hasExplicit) { - // Bottom (seen from inside the room, looking up) stays opaque so the - // ceiling reads as a solid surface. Top uses the transparent - // grid-pattern material so the ceiling stays see-through whenever - // the editor reveals the `ceiling-grid` overlay (placing a - // ceiling-hosted item, or selecting one of its children — e.g. - // after committing a placement). Without this the top mesh shipped - // an opaque surface-role material, so a top-down camera lost view - // of everything under the ceiling once the overlay turned on. + // Textures-off mode takes the themed 'ceiling' role colour — the guaranteed + // escape hatch, independent of any slot override. The bottom (seen from + // inside the room, looking up) stays opaque so the ceiling reads as a solid + // surface; the top keeps the transparent grid material so a top-down camera + // can see through the ceiling whenever the `ceiling-grid` overlay is + // revealed (placing a ceiling-hosted item, or selecting one of its + // children). Without that the top mesh would ship an opaque surface-role + // material and a top-down camera would lose everything under the ceiling. + if (!textures) { const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme) return { topMaterial: getCeilingMaterials(ceilingColor).topMaterial, @@ -116,14 +78,26 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { } } - const preset = getMaterialPresetByRef(node.materialPreset) - const props = preset?.mapProperties ?? resolveMaterial(node.material) - const color = props.color || '#999999' - return getCeilingMaterials(color) + // Unified slot override — shared scene material or catalog `library:` finish + // (resolved to its base colour; a ceiling renders flat-tinted, not mapped). + const slotColor = ceilingColorFromRef(node.slots?.surface, sceneMaterials) + if (slotColor) return getCeilingMaterials(slotColor) + + // Legacy inline material / preset (scenes painted before the slot model). + if (node.materialPreset || node.material) { + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) + return getCeilingMaterials(props.color || '#999999') + } + + // Declared slot default. + return getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR) }, [ textures, colorPreset, sceneTheme, + sceneMaterials, + node.slots, node.materialPreset, node.material, node.material?.preset, diff --git a/packages/nodes/src/ceiling/slots.ts b/packages/nodes/src/ceiling/slots.ts new file mode 100644 index 000000000..7b7c48a99 --- /dev/null +++ b/packages/nodes/src/ceiling/slots.ts @@ -0,0 +1,12 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type CeilingSlotId = 'surface' + +// Soft white — the default underside colour for an unpainted ceiling. (A +// ceiling renders flat-tinted, so this is a colour, not a `library:` finish.) +export const CEILING_SLOT_DEFAULT_COLOR = '#f2eee6' + +/** A ceiling exposes a single paintable underside surface. */ +export function ceilingSlots(): SlotDeclaration[] { + return [{ slotId: 'surface', label: 'Surface', default: CEILING_SLOT_DEFAULT_COLOR }] +} diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index aa90fbb28..f4f901c9c 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -7,8 +7,10 @@ import { import { buildColumnFloorplan } from './floorplan' import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances' import { columnFloorplanMoveTarget } from './floorplan-move' +import { columnPaint } from './paint' import { columnParametrics } from './parametrics' import { ColumnNode } from './schema' +import { columnSlots } from './slots' // Limits + offsets shared with the in-world arrows. Mirrors the floors // the renderer clamps to (`Math.max(0.2, node.height)` etc.) so a drag @@ -325,6 +327,8 @@ export const columnDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + slots: (node) => columnSlots(node as ColumnNodeType), + paint: columnPaint, // Slab elevation lift via the generic ``. floorPlaced: { footprint: (node) => { diff --git a/packages/nodes/src/column/paint.ts b/packages/nodes/src/column/paint.ts new file mode 100644 index 000000000..212d9f9fe --- /dev/null +++ b/packages/nodes/src/column/paint.ts @@ -0,0 +1,18 @@ +import type { AnyNode } from '@pascal-app/core' +import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' +import type { ColumnNode } from './schema' + +export const columnPaint = createSlotPaintCapability({ + resolveRole: (args) => { + const slotId = args.hitObject?.userData?.slotId + return typeof slotId === 'string' ? slotId : null + }, + applyPreview: previewSlotByUserData, + legacyEffective: (node: AnyNode) => { + const column = node as ColumnNode + if (column.materialPreset || column.material) { + return { material: column.material, materialPreset: column.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index 05c9e5ab4..fb070206a 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -5,6 +5,7 @@ import { useLiveNodeOverrides, useLiveTransforms, useRegistry, + useScene, } from '@pascal-app/core' import { baseMaterial, @@ -17,21 +18,52 @@ import { createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { createContext, useContext, useEffect, useMemo, useRef } from 'react' +import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' +import { + COLUMN_BASE_DEFAULT, + COLUMN_CAPITAL_DEFAULT, + COLUMN_FRAME_DEFAULT, + COLUMN_SHAFT_DEFAULT, + type ColumnSlotId, +} from './slots' + +type ColumnSlotMaterials = Record +type SceneMaterials = ReturnType['materials'] + +const DEFAULT_COLUMN_MATERIAL = baseMaterial() +const DEFAULT_COLUMN_SLOT_MATERIALS = createSingleColumnMaterialMap(DEFAULT_COLUMN_MATERIAL) -const ColumnMaterialContext = createContext(baseMaterial()) +const ColumnMaterialContext = createContext(DEFAULT_COLUMN_SLOT_MATERIALS) +const ColumnSlotContext = createContext('shaft') const ColumnEdgeSoftnessContext = createContext(0.025) function ColumnMaterial() { - const material = useContext(ColumnMaterialContext) + const slotId = useContext(ColumnSlotContext) + const materials = useContext(ColumnMaterialContext) + const material = materials[slotId] ?? materials.shaft return } -function createColumnMaterial({ +function ColumnSlot({ children, slotId }: { children: ReactNode; slotId: ColumnSlotId }) { + return {children} +} + +function createSingleColumnMaterialMap(material: Material): ColumnSlotMaterials { + return { + shaft: material, + base: material, + capital: material, + frame: material, + } +} + +function createLegacyColumnMaterial({ material, materialPreset, shading, @@ -50,6 +82,99 @@ function createColumnMaterial({ return baseMaterial(shading) } +function resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId, + textures, +}: { + colorPreset: ColorPreset + legacyMaterial: Material | null + node: ColumnNode + sceneMaterials: SceneMaterials + shading: RenderShading + slotId: ColumnSlotId + textures: boolean +}): Material { + if (!textures) return createSurfaceRoleMaterial('wall', colorPreset) + + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) + if (resolved) return resolved + } + + if (legacyMaterial) return legacyMaterial + + if (slotId === 'frame') return resolveSlotDefaultMaterial(COLUMN_FRAME_DEFAULT, shading) + if (slotId === 'base') return resolveSlotDefaultMaterial(COLUMN_BASE_DEFAULT, shading) + if (slotId === 'capital') return resolveSlotDefaultMaterial(COLUMN_CAPITAL_DEFAULT, shading) + return resolveSlotDefaultMaterial(COLUMN_SHAFT_DEFAULT, shading) +} + +function createColumnSlotMaterials({ + colorPreset, + material, + materialPreset, + node, + sceneMaterials, + shading, + textures, +}: Pick & { + colorPreset: ColorPreset + node: ColumnNode + sceneMaterials: SceneMaterials + shading: RenderShading + textures: boolean +}): ColumnSlotMaterials { + const legacyMaterial = + materialPreset || material + ? createLegacyColumnMaterial({ colorPreset, material, materialPreset, shading, textures }) + : null + + return { + shaft: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'shaft', + textures, + }), + base: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'base', + textures, + }), + capital: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'capital', + textures, + }), + frame: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'frame', + textures, + }), + } +} + function getSegments(node: ColumnNode) { if (node.crossSection === 'octagonal') return 8 if (node.crossSection === 'sixteen-sided') return 16 @@ -126,6 +251,7 @@ function MappedBox({ width: number }) { const edgeSoftness = useContext(ColumnEdgeSoftnessContext) + const slotId = useContext(ColumnSlotContext) const minDimension = Math.max(0, Math.min(width, height, depth)) const bevelRadius = softenEdges ? Math.min(Math.max(0, edgeSoftness), minDimension * 0.35) : 0 const geometry = useMemo(() => { @@ -136,7 +262,14 @@ function MappedBox({ if (!geometry) return null return ( - + @@ -154,6 +287,7 @@ function FlatEndedBeam({ start: VectorTuple width: number }) { + const slotId = useContext(ColumnSlotContext) const dx = end[0] - start[0] const dy = end[1] - start[1] const dz = end[2] - start[2] @@ -244,7 +378,7 @@ function FlatEndedBeam({ if (!geometry) return null return ( - + @@ -763,6 +897,7 @@ function MappedCylinder({ rotation?: VectorTuple segments?: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (height <= 0 || radius <= 0 || radiusBottom < 0 || radiusTop < 0) return null return createColumnCylinderGeometry({ @@ -778,7 +913,14 @@ function MappedCylinder({ if (!geometry) return null return ( - + @@ -800,6 +942,7 @@ function MappedCone({ rotation?: VectorTuple segments?: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (height <= 0 || radiusX <= 0 || radiusZ <= 0) return null return createColumnCylinderGeometry({ @@ -815,7 +958,14 @@ function MappedCone({ if (!geometry) return null return ( - + @@ -833,6 +983,7 @@ function MappedSphere({ segments?: number verticalSegments?: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (radius <= 0) return null return createColumnSphereGeometry(radius, segments, verticalSegments) @@ -841,7 +992,7 @@ function MappedSphere({ if (!geometry) return null return ( - + @@ -867,6 +1018,7 @@ function MappedTorus({ scaleZ?: number tubeRadius: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (ringRadius <= 0 || tubeRadius <= 0) return null return createColumnTorusGeometry({ @@ -882,7 +1034,14 @@ function MappedTorus({ if (!geometry) return null return ( - + @@ -2094,55 +2253,67 @@ function ColumnBody({ node }: { node: ColumnNode }) { return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) - return node.supportStyle === 'a-frame' ? ( - - ) : node.supportStyle === 'y-frame' ? ( - - ) : node.supportStyle === 'v-frame' ? ( - - ) : node.supportStyle === 'x-brace' ? ( - - ) : node.supportStyle === 'k-brace' ? ( - - ) : node.supportStyle === 'single-strut' ? ( - - ) : node.supportStyle === 'tripod' ? ( - - ) : node.supportStyle === 'trestle' ? ( - - ) : node.supportStyle === 'portal-frame' ? ( - - ) : node.supportStyle === 'box-frame' ? ( - - ) : ( + if (node.supportStyle !== 'vertical') { + const support = + node.supportStyle === 'a-frame' ? ( + + ) : node.supportStyle === 'y-frame' ? ( + + ) : node.supportStyle === 'v-frame' ? ( + + ) : node.supportStyle === 'x-brace' ? ( + + ) : node.supportStyle === 'k-brace' ? ( + + ) : node.supportStyle === 'single-strut' ? ( + + ) : node.supportStyle === 'tripod' ? ( + + ) : node.supportStyle === 'trestle' ? ( + + ) : node.supportStyle === 'portal-frame' ? ( + + ) : ( + + ) + return {support} + } + + return ( <> - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } @@ -2152,7 +2323,7 @@ function ColumnBody({ node }: { node: ColumnNode }) { * cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree * as the real renderer via `` but: * - clones the material and makes it transparent (cloning is required: - * `createColumnMaterial` can hand back a shared/cached instance, and + * `createLegacyColumnMaterial` can hand back a shared/cached instance, and * mutating it would turn every committed column see-through); * - disables raycast on every mesh so the ghost doesn't intercept the * placement cursor ray (which would stall `grid:move`); @@ -2164,8 +2335,8 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => { const colorPreset = useViewer((state) => state.colorPreset) const groupRef = useRef(null) - const material = useMemo(() => { - const ghost = createColumnMaterial({ + const materials = useMemo(() => { + const ghost = createLegacyColumnMaterial({ material: node.material, materialPreset: node.materialPreset, shading, @@ -2175,10 +2346,15 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => { ghost.transparent = true ghost.opacity = 0.5 ghost.depthWrite = false - return ghost + return createSingleColumnMaterialMap(ghost) }, [shading, textures, colorPreset, node.material, node.materialPreset]) - useEffect(() => () => material.dispose(), [material]) + useEffect( + () => () => { + for (const material of new Set(Object.values(materials))) material.dispose() + }, + [materials], + ) // Strip pointer events off the freshly-built meshes every render — the // geometry tree rebuilds when the ghost's dimensions change, so a one-shot @@ -2190,7 +2366,7 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => { }) return ( - + @@ -2216,11 +2392,14 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) - const material = useMemo( + const sceneMaterials = useScene((state) => state.materials) + const materials = useMemo( () => - createColumnMaterial({ + createColumnSlotMaterials({ material: node.material, materialPreset: node.materialPreset, + node, + sceneMaterials, shading, textures, colorPreset, @@ -2234,13 +2413,15 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { node.material?.properties, node.material?.texture, node.materialPreset, + node.slots, + sceneMaterials, ], ) useRegistry(node.id, node.type, ref) return ( - + = { // placed. Host apps strip these at preset-save time via // `getHostRefFields(def)`. hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + // Panel / glass slots painted through the registry. The door system tags + // each mesh with its `userData.slotId`; paint writes `node.slots`. + slots: () => doorSlots(), + paint: doorPaint, }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/paint.ts b/packages/nodes/src/door/paint.ts new file mode 100644 index 000000000..e236a5e38 --- /dev/null +++ b/packages/nodes/src/door/paint.ts @@ -0,0 +1,16 @@ +import { + createSlotPaintCapability, + previewSlotByUserData, + resolveSlotByReRaycast, +} from '../shared/slot-paint' + +/** + * Door paint on the unified slot model. The door's opening proxy (a proud, + * invisible cutout) wins the shared scene raycast over the wall in front of the + * recessed door body, so `resolveSlotByReRaycast` re-raycasts the door's own + * subtree to find the part (panel / frame / glass / hardware) under the cursor. + */ +export const doorPaint = createSlotPaintCapability({ + resolveRole: resolveSlotByReRaycast, + applyPreview: previewSlotByUserData, +}) diff --git a/packages/nodes/src/door/slots.ts b/packages/nodes/src/door/slots.ts new file mode 100644 index 000000000..2e2218793 --- /dev/null +++ b/packages/nodes/src/door/slots.ts @@ -0,0 +1,25 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type DoorSlotId = 'panel' | 'frame' | 'glass' | 'hardware' + +// Picker swatches. Rendering falls back to the live body/glass/hardware defaults +// (which already track shading + theme), so these are just the indicator colours. +const PANEL_DEFAULT = 'library:preset-softwhite' +const FRAME_DEFAULT = 'library:preset-softwhite' +const GLASS_DEFAULT = 'library:preset-glass' +// Chrome — a flat (non-PBR) catalog metal finish. +const HARDWARE_DEFAULT = 'library:metal-chrome' + +/** + * A door exposes four paintable slots: `panel` (leaf faces), `frame`, `glass`, + * and `hardware` (handle / hinges / closer / panic bar). The opening reveal + * keeps its own material. + */ +export function doorSlots(): SlotDeclaration[] { + return [ + { slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT }, + { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, + ] +} diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts index c692cf395..7f505bd3b 100644 --- a/packages/nodes/src/elevator/definition.ts +++ b/packages/nodes/src/elevator/definition.ts @@ -12,8 +12,10 @@ import { } from '@pascal-app/core' import { buildElevatorFloorplan } from './floorplan' import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances' +import { elevatorPaint } from './paint' import { elevatorParametrics } from './parametrics' import { ElevatorNode } from './schema' +import { elevatorSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.22 const HEIGHT_HANDLE_OFFSET = 0.3 @@ -223,6 +225,8 @@ export const elevatorDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + slots: (node) => elevatorSlots(node as ElevatorNodeType), + paint: elevatorPaint, }, parametrics: elevatorParametrics, diff --git a/packages/nodes/src/elevator/paint.ts b/packages/nodes/src/elevator/paint.ts new file mode 100644 index 000000000..ac38e484a --- /dev/null +++ b/packages/nodes/src/elevator/paint.ts @@ -0,0 +1,7 @@ +import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' + +export const elevatorPaint = createSlotPaintCapability({ + resolveRole: (args) => (args.hitObject?.userData?.slotId as string) ?? null, + applyPreview: previewSlotByUserData, + legacyEffective: () => null, +}) diff --git a/packages/nodes/src/elevator/renderer.tsx b/packages/nodes/src/elevator/renderer.tsx index 31410b4ee..9d0fb3f68 100644 --- a/packages/nodes/src/elevator/renderer.tsx +++ b/packages/nodes/src/elevator/renderer.tsx @@ -28,11 +28,13 @@ import { createDefaultMaterial, createSurfaceRoleMaterial, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' -import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react' import { BoxGeometry, CylinderGeometry, @@ -43,6 +45,13 @@ import { TorusGeometry, } from 'three' import { useShallow } from 'zustand/react/shallow' +import { + ELEVATOR_CAB_SLOT_DEFAULT, + ELEVATOR_DOORS_SLOT_DEFAULT, + ELEVATOR_GLASS_SLOT_DEFAULT, + ELEVATOR_SHAFT_SLOT_DEFAULT, + type ElevatorSlotId, +} from './slots' const DEFAULT_STRUCTURE_WHITE = '#f2f0ed' const SHAFT_WALL_COLOR = DEFAULT_STRUCTURE_WHITE @@ -360,48 +369,102 @@ function getElevatorMaterials( return materials } -let { - SHAFT_WALL_MATERIAL, - SHAFT_SIDE_MATERIAL, - SHAFT_TRIM_MATERIAL, - CAB_MATERIAL, - DOOR_MATERIAL, - DOOR_GROOVE_MATERIAL, - GLASS_MATERIAL, - PANEL_MATERIAL, - LANDING_PANEL_MATERIAL, - INDICATOR_SCREEN_MATERIALS, - INDICATOR_GLYPH_MATERIALS, - BUTTON_FACE_MATERIALS, - BUTTON_RING_MATERIALS, - BUTTON_GLOW_MATERIALS, - BUTTON_LABEL_MATERIALS, - QUEUE_STRIP_MATERIALS, -} = getElevatorMaterials('rendered') - -function setElevatorMaterials( +type ElevatorSceneMaterials = ReturnType['materials'] + +function resolveElevatorFinishMaterial( + node: ElevatorNode, + slotId: ElevatorSlotId, + slotDefault: string, + sceneMaterials: ElevatorSceneMaterials, shading: RenderShading, - textures = true, - colorPreset: ColorPreset = 'clay', -) { - ;({ - SHAFT_WALL_MATERIAL, - SHAFT_SIDE_MATERIAL, - SHAFT_TRIM_MATERIAL, - CAB_MATERIAL, - DOOR_MATERIAL, - DOOR_GROOVE_MATERIAL, - GLASS_MATERIAL, - PANEL_MATERIAL, - LANDING_PANEL_MATERIAL, - INDICATOR_SCREEN_MATERIALS, - INDICATOR_GLYPH_MATERIALS, - BUTTON_FACE_MATERIALS, - BUTTON_RING_MATERIALS, - BUTTON_GLOW_MATERIALS, - BUTTON_LABEL_MATERIALS, - QUEUE_STRIP_MATERIALS, - } = getElevatorMaterials(shading, textures, colorPreset)) + roughness: number, +): Material { + const ref = node.slots?.[slotId] + if (ref) { + const resolved = resolveMaterialRef(ref, sceneMaterials, shading) + if (resolved) return resolved + } + return resolveSlotDefaultMaterial(slotDefault, shading, roughness) +} + +function withElevatorGlassTransparency(material: Material): Material { + const glass = material.clone() + glass.depthWrite = false + glass.opacity = 0.2 + glass.transparent = true + glass.needsUpdate = true + return glass +} + +function getResolvedElevatorMaterials( + node: ElevatorNode, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneMaterials: ElevatorSceneMaterials, +): ElevatorMaterialSet { + const materials = getElevatorMaterials(shading, textures, colorPreset) + if (!textures) return materials + + const cab = resolveElevatorFinishMaterial( + node, + 'cab', + ELEVATOR_CAB_SLOT_DEFAULT, + sceneMaterials, + shading, + 0.48, + ) + const doors = resolveElevatorFinishMaterial( + node, + 'doors', + ELEVATOR_DOORS_SLOT_DEFAULT, + sceneMaterials, + shading, + 0.34, + ) + const shaft = resolveElevatorFinishMaterial( + node, + 'shaft', + ELEVATOR_SHAFT_SLOT_DEFAULT, + sceneMaterials, + shading, + 0.56, + ) + const glass = withElevatorGlassTransparency( + resolveElevatorFinishMaterial( + node, + 'glass', + ELEVATOR_GLASS_SLOT_DEFAULT, + sceneMaterials, + shading, + 0.08, + ), + ) + + return { + ...materials, + SHAFT_WALL_MATERIAL: shaft, + SHAFT_SIDE_MATERIAL: shaft, + SHAFT_TRIM_MATERIAL: shaft, + CAB_MATERIAL: cab, + DOOR_MATERIAL: doors, + DOOR_GROOVE_MATERIAL: doors, + GLASS_MATERIAL: glass, + } +} + +const DEFAULT_ELEVATOR_MATERIALS = getElevatorMaterials('rendered') +const ElevatorMaterialsContext = createContext(DEFAULT_ELEVATOR_MATERIALS) + +function useElevatorMaterialSet(): ElevatorMaterialSet { + return useContext(ElevatorMaterialsContext) +} + +const ELEVATOR_SLOT_USER_DATA: Record = { + cab: { slotId: 'cab' }, + doors: { slotId: 'doors' }, + shaft: { slotId: 'shaft' }, + glass: { slotId: 'glass' }, } type ElevatorButtonAction = 'open-door' | 'request-level' @@ -449,6 +512,7 @@ function BoxPrimitive({ receiveShadow = false, rotation, scale, + slotId, }: { castShadow?: boolean material: Material @@ -456,6 +520,7 @@ function BoxPrimitive({ receiveShadow?: boolean rotation?: Vector3Tuple scale: Vector3Tuple + slotId?: ElevatorSlotId }) { return ( ) } @@ -597,6 +663,8 @@ function ElevatorFloorIndicator({ scale?: number showReadout?: boolean }) { + const { INDICATOR_GLYPH_MATERIALS, INDICATOR_SCREEN_MATERIALS, PANEL_MATERIAL } = + useElevatorMaterialSet() const glyphMaterial = active ? INDICATOR_GLYPH_MATERIALS.active : INDICATOR_GLYPH_MATERIALS.idle const screenMaterial = active ? INDICATOR_SCREEN_MATERIALS.active @@ -721,6 +789,12 @@ function ElevatorMeshButton({ queued: boolean radius?: number }) { + const { + BUTTON_FACE_MATERIALS, + BUTTON_GLOW_MATERIALS, + BUTTON_LABEL_MATERIALS, + BUTTON_RING_MATERIALS, + } = useElevatorMaterialSet() const state = disabled ? 'disabled' : active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) @@ -845,6 +919,7 @@ function DoorLeaf({ y: number z: number }) { + const { DOOR_GROOVE_MATERIAL, DOOR_MATERIAL, GLASS_MATERIAL } = useElevatorMaterialSet() const ref = useRef(null) const getLeafX = (openAmount: number) => getElevatorDoorLeafX(side, width, openAmount, doorStyle) const leafWidth = getElevatorDoorLeafWidth(width, doorStyle) @@ -880,6 +955,7 @@ function DoorLeaf({ position={[0, height / 2 - railHeight / 2, 0]} receiveShadow scale={[leafWidth, railHeight, 0.05]} + slotId="doors" /> ) : ( @@ -916,11 +996,13 @@ function DoorLeaf({ position={[0, 0, 0]} receiveShadow scale={[leafWidth, height, 0.05]} + slotId="doors" /> {resolvedPanelStyle === 'segmented-panel' ? Array.from({ length: segmentCount - 1 }).map((_, index) => ( @@ -929,6 +1011,7 @@ function DoorLeaf({ material={DOOR_GROOVE_MATERIAL} position={[0, -panelInsetHeight / 2 + segmentSpacing * (index + 1), -0.03]} scale={[panelInsetWidth, 0.018, 0.012]} + slotId="doors" /> )) : null} @@ -936,11 +1019,13 @@ function DoorLeaf({ material={DOOR_GROOVE_MATERIAL} position={[0, panelInsetHeight / 2, -0.029]} scale={[panelInsetWidth, 0.012, 0.01]} + slotId="doors" /> )} @@ -1011,6 +1096,7 @@ function LandingDoorFrame({ shaftWidth: number z: number }) { + const { SHAFT_TRIM_MATERIAL, SHAFT_WALL_MATERIAL } = useElevatorMaterialSet() const wallDepth = 0.09 const levelHeight = Math.max(levelTopY - levelY, 0.01) const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) @@ -1026,6 +1112,7 @@ function LandingDoorFrame({ position={[-jambCenterOffset, levelY + levelHeight / 2, z]} receiveShadow scale={[jambWidth, levelHeight, wallDepth]} + slotId="shaft" /> {headerHeight > 0.01 && ( )} ) @@ -1119,6 +1212,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id)) const renderNode = useMemo( @@ -1128,8 +1222,19 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const levelContextNodes = useScene( useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)), ) - - setElevatorMaterials(shading, textures, colorPreset) + const materials = useMemo( + () => getResolvedElevatorMaterials(renderNode, shading, textures, colorPreset, sceneMaterials), + [colorPreset, renderNode, sceneMaterials, shading, textures], + ) + const { + CAB_MATERIAL, + GLASS_MATERIAL, + LANDING_PANEL_MATERIAL, + PANEL_MATERIAL, + QUEUE_STRIP_MATERIALS, + SHAFT_SIDE_MATERIAL, + SHAFT_TRIM_MATERIAL, + } = materials useRegistry(node.id, 'elevator', ref) @@ -1174,6 +1279,7 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const doorStyle = getResolvedDoorStyle(renderNode.doorStyle) const shaftStyle = getResolvedShaftStyle(renderNode.shaftStyle) const shaftShellMaterial = shaftStyle === 'glass' ? GLASS_MATERIAL : SHAFT_SIDE_MATERIAL + const shaftShellSlotId: ElevatorSlotId = shaftStyle === 'glass' ? 'glass' : 'shaft' const shaftTopMaterial = shaftStyle === 'glass' ? SHAFT_TRIM_MATERIAL : SHAFT_SIDE_MATERIAL const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) @@ -1269,217 +1375,232 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { ) return ( - - - - - - - + + - - - - + + - + - + - - {entries.map((entry, index) => { - const column = index % cabButtonColumns - const row = Math.floor(index / cabButtonColumns) - const isDisabledLevel = disabledLevelIds.has(entry.id) - const x = - cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX - const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY - - return ( - - ) - })} - + + + + - - - {entrySpans.map(({ entry, levelTopY }) => { - const isCurrentLevel = activeLevelId === entry.id - const isDisabledLevel = disabledLevelIds.has(entry.id) - const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) - const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) - const isPendingLevel = pendingLevelId === entry.id - const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel - - return ( - - + - { + const column = index % cabButtonColumns + const row = Math.floor(index / cabButtonColumns) + const isDisabledLevel = disabledLevelIds.has(entry.id) + const x = + cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX + const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY + + return ( + + ) + })} + - - - + + + {entrySpans.map(({ entry, levelTopY }) => { + const isCurrentLevel = activeLevelId === entry.id + const isDisabledLevel = disabledLevelIds.has(entry.id) + const isServiceOnlyLevel = serviceOnlyLevelIds.has(entry.id) + const isQueuedLevel = !isDisabledLevel && queuedLevelIds.has(entry.id) + const isPendingLevel = pendingLevelId === entry.id + const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel + + return ( + + - 0.5} - buttonKind="landing" - disabled={isDisabledLevel || isServiceOnlyLevel} + - + + + 0.5 + } + buttonKind="landing" + disabled={isDisabledLevel || isServiceOnlyLevel} + elevatorId={elevatorId} + levelId={entry.id as AnyNodeId} + position={[0, 0.06, -0.045]} + queued={isQueuedLevel} + radius={0.045} + /> + + - - ) - })} - + ) + })} + + ) } diff --git a/packages/nodes/src/elevator/slots.ts b/packages/nodes/src/elevator/slots.ts new file mode 100644 index 000000000..7f1cc0483 --- /dev/null +++ b/packages/nodes/src/elevator/slots.ts @@ -0,0 +1,30 @@ +import { + type ElevatorNode, + getResolvedElevatorDoorPanelStyle, + getResolvedElevatorShaftStyle, + type SlotDeclaration, +} from '@pascal-app/core' + +export type ElevatorSlotId = 'cab' | 'doors' | 'shaft' | 'glass' + +export const ELEVATOR_CAB_SLOT_DEFAULT = 'library:preset-softwhite' +export const ELEVATOR_DOORS_SLOT_DEFAULT = 'library:metal-steel' +export const ELEVATOR_SHAFT_SLOT_DEFAULT = 'library:preset-lightgrey' +export const ELEVATOR_GLASS_SLOT_DEFAULT = 'library:preset-glass' + +export function elevatorSlots(node: ElevatorNode): SlotDeclaration[] { + const slots: SlotDeclaration[] = [ + { slotId: 'cab', label: 'Cab', default: ELEVATOR_CAB_SLOT_DEFAULT }, + { slotId: 'doors', label: 'Doors', default: ELEVATOR_DOORS_SLOT_DEFAULT }, + { slotId: 'shaft', label: 'Shaft', default: ELEVATOR_SHAFT_SLOT_DEFAULT }, + ] + + const hasGlass = + getResolvedElevatorShaftStyle(node.shaftStyle) === 'glass' || + getResolvedElevatorDoorPanelStyle(node.doorPanelStyle) === 'glass-frame' + + if (hasGlass) + slots.push({ slotId: 'glass', label: 'Glass', default: ELEVATOR_GLASS_SLOT_DEFAULT }) + + return slots +} diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 2d3aa09f8..f184cf354 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -3,8 +3,10 @@ import { buildFenceFloorplan } from './floorplan' import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances' import { fenceFloorplanMoveTarget } from './floorplan-move' import { buildFenceGeometry } from './geometry' +import { fencePaint } from './paint' import { fenceParametrics } from './parametrics' import { FenceNode } from './schema' +import { fenceSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.27 const SIDE_HANDLE_MIN_OFFSET = 0.33 @@ -163,6 +165,8 @@ export const fenceDefinition: NodeDefinition = { surfaces: { sides: { faces: 'all' } }, duplicable: true, deletable: true, + slots: (node) => fenceSlots(node as FenceNodeType), + paint: fencePaint, // Placed by drawing the span with the two-click tool; a saved preset // seeds its build parameters via `toolDefaults.fence` (see `tool.tsx` // and `createFenceOnCurrentLevel`). diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index e87f7ebfe..e48f63550 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -1,37 +1,133 @@ +import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' import { - DEFAULT_STAIR_MATERIAL, - generateFenceGeometry, + applyMaterialPresetToMaterials, + type ColorPreset, + createDefaultMaterial, + createMaterial, + createSurfaceRoleMaterial, + generateFenceSlotGeometries, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, } from '@pascal-app/viewer' -import { Group, Mesh } from 'three' +import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' import type { FenceNode } from './schema' +import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots' /** - * Stage B builder for fence. Reuses the legacy `generateFenceGeometry` - * (pure function from viewer that returns a merged BufferGeometry of - * posts + base + top rail + curve spans) and wraps it in a Mesh-in-Group - * shape the generic `` expects. + * Stage B builder for fence. Splits the geometry into four paintable slots — + * `posts`, `infill`, `base`, `rail` (matching the build options in the panel) — + * each its own Mesh with a `userData.slotId` so the unified slot paint resolves + * and previews per part. Empty groups (no infill / floating base) are skipped. * - * Material is a single shared reference — fences look the same regardless - * of instance, so we don't clone per node. If per-fence material - * customization lands later (color picker on the panel maps to a real - * material), this becomes a per-node lookup. + * Per slot the material resolves: `node.slots[slotId]` (a shared scene material + * or `library:` finish) → the legacy inline `node.material` / `materialPreset` + * (pre-slot-model scenes, applied to every part) → the declared slot default. + * Textures-off collapses every part to the themed joinery role. * - * Phase 6 cleanup moves the 280 lines of geometry math out of the - * legacy `viewer/src/systems/fence/fence-system.tsx` into this folder - * once the legacy system file is deleted. Until then `generateFenceGeometry` - * is publicly re-exported from viewer. + * Phase 6 cleanup moves the geometry math out of the legacy + * `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy + * system file is deleted. Until then `generateFenceSlotGeometries` is publicly + * re-exported from viewer. */ +type FenceMaterial = Material & { + alphaMap?: Texture | null + depthWrite: boolean + opacity: number + transparent: boolean +} + +const FENCE_SLOT_ORDER: FenceSlotId[] = ['posts', 'infill', 'base', 'rail'] + +const fenceMaterialCache = new Map() + +function getFenceSlotMaterial( + node: FenceNode, + slotId: FenceSlotId, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, + sceneMaterials: GeometryContext['materials'], +): Material { + if (!textures) { + return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) + } + + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) + if (resolved) return resolved + } + + if (node.materialPreset || node.material) { + return getLegacyFenceMaterial(node, shading) + } + + return resolveSlotDefaultMaterial(FENCE_SLOT_DEFAULTS[slotId], shading, 0.8) +} + +function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material { + const cacheKey = JSON.stringify({ + shading, + material: node.material ?? null, + materialPreset: node.materialPreset ?? null, + }) + const cached = fenceMaterialCache.get(cacheKey) + if (cached) return cached + + const preset = getMaterialPresetByRef(node.materialPreset) + const material = preset + ? createDefaultMaterial('#ffffff', 0.5, shading) + : node.material + ? createMaterial(node.material, shading).clone() + : createDefaultMaterial('#ffffff', 0.9, shading) + + if (preset) { + applyMaterialPresetToMaterials(material, preset) + } + + const fenceMaterial = material as FenceMaterial + fenceMaterial.transparent = false + fenceMaterial.opacity = 1 + fenceMaterial.alphaMap = null + fenceMaterial.side = FrontSide + fenceMaterial.depthWrite = true + fenceMaterial.needsUpdate = true + + fenceMaterialCache.set(cacheKey, material) + return material +} + export function buildFenceGeometry( node: FenceNode, - _ctx?: unknown, + ctx?: GeometryContext, shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, ): Group { const group = new Group() - const geometry = generateFenceGeometry(node) - const mesh = new Mesh(geometry, DEFAULT_STAIR_MATERIAL(shading)) - mesh.castShadow = true - mesh.receiveShadow = true - group.add(mesh) + const geometries = generateFenceSlotGeometries(node) + + for (const slotId of FENCE_SLOT_ORDER) { + const geometry = geometries[slotId] + if (geometry.getAttribute('position') === undefined) continue + const material = getFenceSlotMaterial( + node, + slotId, + shading, + textures, + colorPreset, + sceneTheme, + ctx?.materials, + ) + const mesh = new Mesh(geometry, material) + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.slotId = slotId + group.add(mesh) + } + return group } diff --git a/packages/nodes/src/fence/paint.ts b/packages/nodes/src/fence/paint.ts new file mode 100644 index 000000000..004563823 --- /dev/null +++ b/packages/nodes/src/fence/paint.ts @@ -0,0 +1,21 @@ +import type { AnyNode, FenceNode, PaintResolveArgs } from '@pascal-app/core' +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +const FENCE_SLOT_IDS = new Set(['posts', 'infill', 'base', 'rail']) + +function resolveFenceRole(args: PaintResolveArgs): string | null { + const slotId = (args.hitObject?.userData as { slotId?: unknown } | undefined)?.slotId + return typeof slotId === 'string' && FENCE_SLOT_IDS.has(slotId) ? slotId : null +} + +export const fencePaint = createSlotPaintCapability({ + resolveRole: resolveFenceRole, + applyPreview: previewGeometrySlot, + legacyEffective: (node: AnyNode) => { + const fence = node as FenceNode + if (fence.materialPreset || fence.material) { + return { material: fence.material, materialPreset: fence.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/fence/slots.ts b/packages/nodes/src/fence/slots.ts new file mode 100644 index 000000000..bda95e929 --- /dev/null +++ b/packages/nodes/src/fence/slots.ts @@ -0,0 +1,32 @@ +import type { SlotDeclaration } from '@pascal-app/core' +import type { FenceNode } from './schema' + +// Slots map 1:1 to the fence panel's build options: the end posts, the infill +// slats (the showInfill toggle), the base kickboard, and the top rail. +export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail' + +export const FENCE_POSTS_SLOT_DEFAULT = 'library:preset-charcoal' +export const FENCE_INFILL_SLOT_DEFAULT = 'library:preset-charcoal' +export const FENCE_BASE_SLOT_DEFAULT = 'library:preset-greige' +export const FENCE_RAIL_SLOT_DEFAULT = 'library:preset-greige' + +export const FENCE_SLOT_DEFAULTS: Record = { + posts: FENCE_POSTS_SLOT_DEFAULT, + infill: FENCE_INFILL_SLOT_DEFAULT, + base: FENCE_BASE_SLOT_DEFAULT, + rail: FENCE_RAIL_SLOT_DEFAULT, +} + +export function fenceSlots(node: FenceNode): SlotDeclaration[] { + const slots: SlotDeclaration[] = [ + { slotId: 'posts', label: 'Posts', default: FENCE_POSTS_SLOT_DEFAULT }, + ] + if (node.showInfill !== false) { + slots.push({ slotId: 'infill', label: 'Infill', default: FENCE_INFILL_SLOT_DEFAULT }) + } + if (node.baseStyle !== 'floating') { + slots.push({ slotId: 'base', label: 'Base', default: FENCE_BASE_SLOT_DEFAULT }) + } + slots.push({ slotId: 'rail', label: 'Rail', default: FENCE_RAIL_SLOT_DEFAULT }) + return slots +} diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 115727d17..c501c986b 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -7,6 +7,7 @@ import { } from '@pascal-app/core' import { buildItemFloorplan } from './floorplan' import { itemFloorplanMoveTarget } from './floorplan-move' +import { itemPaint } from './paint' import { itemParametrics } from './parametrics' import { ItemNode } from './schema' @@ -199,6 +200,7 @@ export const itemDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: itemPaint, // Items participate in compositions — e.g. "table-with-plants", // "shelf-with-books-on-top" — so they're presettable in their own // right (and as descendants of presettable parents). The GLB-kind diff --git a/packages/nodes/src/item/paint.ts b/packages/nodes/src/item/paint.ts new file mode 100644 index 000000000..b8b191e71 --- /dev/null +++ b/packages/nodes/src/item/paint.ts @@ -0,0 +1,256 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type ItemNode, + type MaterialSchema, + type PaintCapability, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import type { Material, Mesh } from 'three' + +type SlotTag = string | null | (string | null)[] + +type SlotUserData = { + slotId?: SlotTag +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) return false + } + return true + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + const bKeys = Object.keys(bRecord) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false + if (!deepEqual(aRecord[key], bRecord[key])) return false + } + return true + } + return false +} + +function getSlotTag(mesh: Mesh): SlotTag | undefined { + return (mesh.userData as SlotUserData).slotId +} + +function slotTagContainsRole(tag: SlotTag | undefined, role: string): boolean { + if (Array.isArray(tag)) return tag.includes(role) + return tag === role +} + +function resolveItemSlotId(args: { + materialIndex: number | null + hitObject?: { userData?: SlotUserData } +}): string | null { + const tag = args.hitObject?.userData?.slotId + const slotId = Array.isArray(tag) + ? (tag[args.materialIndex ?? 0] ?? null) + : typeof tag === 'string' + ? tag + : null + return slotId +} + +function buildItemSlotsPatch( + node: ItemNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const slots = { ...(node.slots ?? {}) } + if (material === undefined && materialPreset === undefined) { + delete slots[role] + return { slots } + } + if (materialPreset) { + slots[role] = materialPreset + return { slots } + } + return { slots } +} + +function findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitNewSceneMaterialAndSlots( + nodeId: AnyNodeId, + nextSlots: ItemNode['slots'], + sceneMaterial: SceneMaterial, +): void { + // Creating the scene material and setting the slot ref are one logical + // edit, so apply both in a single `set` — zundo records one history entry, + // and one undo removes both the ref and its (now orphaned) material. + useScene.setState((state) => { + if (state.readOnly) return state + const currentNode = state.nodes[nodeId] + if (!currentNode || currentNode.type !== 'item') return state + return { + materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...state.nodes, + [nodeId]: { ...currentNode, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) +} + +function commitItemPaint( + node: ItemNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + const nodeId = node.id as AnyNodeId + const state = useScene.getState() + const currentNode = (state.nodes[nodeId] as ItemNode | undefined) ?? node + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + + if (material === undefined && materialPreset === undefined) { + ref = undefined + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = findMatchingSceneMaterial(state.materials, material) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + const nextSlots = { ...(currentNode.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + + if (newSceneMaterial) { + commitNewSceneMaterialAndSlots(nodeId, nextSlots, newSceneMaterial) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +function buildPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + const shading = useViewer.getState().shading + if (materialPreset) { + const parsed = parseMaterialRef(materialPreset) + if (parsed?.kind === 'scene') { + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + return sceneMaterial ? createMaterial(sceneMaterial.material, shading) : null + } + return createMaterialFromPresetRef(materialPreset, shading) + } + if (material) return createMaterial(material, shading) + return null +} + +function applyItemPreview( + role: string, + root: import('three').Object3D, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): (() => void) | null { + const previewMaterial = buildPreviewMaterial(material, materialPreset) + if (!previewMaterial) return () => {} + + const restores: Array<() => void> = [] + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const tag = getSlotTag(mesh) + if (!slotTagContainsRole(tag, role)) return + + if (Array.isArray(tag)) { + const current = mesh.material as Material | Material[] + if (Array.isArray(current)) { + const previousArray = [...current] + const nextArray = [...current] + let changed = false + for (let index = 0; index < tag.length; index += 1) { + if (tag[index] !== role || !nextArray[index]) continue + nextArray[index] = previewMaterial + changed = true + } + if (!changed) return + mesh.material = nextArray + restores.push(() => { + mesh.material = previousArray + }) + return + } + if (tag[0] !== role) return + } + + const previous = mesh.material + mesh.material = previewMaterial + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } +} + +export const itemPaint: PaintCapability = { + resolveRole: ({ materialIndex, hitObject }) => + resolveItemSlotId({ materialIndex, hitObject: hitObject as { userData?: SlotUserData } }), + buildPatch: ({ node, role, material, materialPreset }) => + buildItemSlotsPatch(node as ItemNode, role, material, materialPreset) as Partial, + commit: ({ node, role, material, materialPreset }) => + commitItemPaint(node as ItemNode, role, material, materialPreset), + applyPreview: ({ role, root, material, materialPreset }) => + applyItemPreview(role, root, material, materialPreset), + getEffectiveMaterial: ({ node, role }) => { + const ref = (node as ItemNode).slots?.[role] + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') { + return { material: undefined, materialPreset: ref } + } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return { material: sceneMaterial.material, materialPreset: undefined } + }, +} diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 2c4adf6c9..dc18848ca 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -3,17 +3,21 @@ import { type AnimationEffect, type AnyNodeId, + deriveSlotId, getScaledDimensions, type Interactive, type ItemNode, + isSlotMaterialName, + LIBRARY_MATERIAL_REF_PREFIX, type LightEffect, + SCENE_MATERIAL_REF_PREFIX, + toLibraryMaterialRef, useInteractive, useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' import { - baseMaterial, type ColorPreset, createDefaultMaterial, createSurfaceRoleMaterial, @@ -22,6 +26,7 @@ import { NodeRenderer, type RenderShading, resolveCdnUrl, + resolveMaterialRef, useItemLightPool, useNodeEvents, useViewer, @@ -30,7 +35,7 @@ import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' import { useGLTF } from '@react-three/drei/core/Gltf' import { useFrame } from '@react-three/fiber' -import { Suspense, useEffect, useMemo, useRef } from 'react' +import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { AnimationAction, Group, Material, Mesh } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' @@ -44,17 +49,132 @@ type MutableMaterial = Material & { wireframe?: boolean } -const getMaterialForOriginal = ( - original: Material, - shading: RenderShading, - textures: boolean, - colorPreset: ColorPreset, -): Material => { - if (original.name.toLowerCase() === 'glass') { - return glassMaterial +type CapturedSingleItemMaterialData = { + captured: true + authoredMaterials: Material + curatedRefs: string | undefined + slotIds: string | null +} + +type CapturedMultiItemMaterialData = { + captured: true + authoredMaterials: Material[] + curatedRefs: (string | undefined)[] + slotIds: (string | null)[] +} + +type CapturedItemMaterialData = CapturedSingleItemMaterialData | CapturedMultiItemMaterialData + +type ItemMeshUserData = Mesh['userData'] & { + pascalItemMaterialCapture?: CapturedItemMaterialData + slotId?: string | null | (string | null)[] +} + +type SceneMaterials = ReturnType['materials'] + +const getAuthoredSlotId = (material: Material): string | null => + isSlotMaterialName(material.name) ? deriveSlotId(material.name) : null + +function curatedRefFromMaterial(material: Material): string | undefined { + const raw = (material.userData as { pascal_material?: unknown }).pascal_material + if (typeof raw !== 'string' || raw.length === 0) return undefined + if (raw.startsWith(LIBRARY_MATERIAL_REF_PREFIX) || raw.startsWith(SCENE_MATERIAL_REF_PREFIX)) { + return raw + } + return toLibraryMaterialRef(raw) +} + +const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => { + const userData = mesh.userData as ItemMeshUserData + const captured = userData.pascalItemMaterialCapture + if (captured?.captured) { + userData.slotId = captured.slotIds + return captured + } + + if (Array.isArray(mesh.material)) { + const authoredMaterials = mesh.material.slice() + const slotIds = authoredMaterials.map(getAuthoredSlotId) + const curatedRefs = authoredMaterials.map(curatedRefFromMaterial) + const next: CapturedItemMaterialData = { + captured: true, + authoredMaterials, + curatedRefs, + slotIds, + } + userData.pascalItemMaterialCapture = next + userData.slotId = slotIds + return next + } + + const slotId = getAuthoredSlotId(mesh.material) + const curatedRef = curatedRefFromMaterial(mesh.material) + const next: CapturedItemMaterialData = { + captured: true, + authoredMaterials: mesh.material, + curatedRefs: curatedRef, + slotIds: slotId, + } + userData.pascalItemMaterialCapture = next + userData.slotId = slotId + return next +} + +const isCapturedMaterialArray = ( + captured: CapturedItemMaterialData, +): captured is CapturedMultiItemMaterialData => Array.isArray(captured.authoredMaterials) + +const isGlassMaterial = (material: Material): boolean => + material === glassMaterial || material.name.toLowerCase() === 'glass' + +const clampGeometryGroups = (mesh: Mesh, matCount: number): void => { + if (mesh.geometry.groups.length === 0) return + + const needsClamp = mesh.geometry.groups.some( + (group) => group.materialIndex !== undefined && group.materialIndex >= matCount, + ) + if (!needsClamp) return + + mesh.geometry = mesh.geometry.clone() + for (const group of mesh.geometry.groups) { + if (group.materialIndex !== undefined && group.materialIndex >= matCount) { + group.materialIndex = 0 + } } +} + +const resolveItemMaterial = ( + authoredMaterial: Material, + slotId: string | null, + curatedRef: string | undefined, + { + colorPreset, + nodeSlots, + sceneMaterials, + shading, + textures, + }: { + colorPreset: ColorPreset + nodeSlots: ItemNode['slots'] + sceneMaterials: SceneMaterials + shading: RenderShading + textures: boolean + }, +): Material => { + // Monochrome (textures off): collapse to the themed furnishing clay colour. if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset) - return baseMaterial(shading) + if (authoredMaterial.name.toLowerCase() === 'glass') return glassMaterial + if (slotId != null) { + const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading) + if (override) return override + const curated = resolveMaterialRef(curatedRef, sceneMaterials, shading) + if (curated) return curated + return authoredMaterial + } + // Colored (textures on): show the item's real authored material — its + // textures, vertex colours, and default colours — for every item, not just + // slot-authored ones (no more strip-to-clay default). + return authoredMaterial } const BrokenItemFallback = ({ node }: { node: ItemNode }) => { @@ -182,6 +302,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) + const sceneMaterials = useScene((s) => s.materials) // Freeze the interactive definition at mount — asset schemas don't change at runtime const interactiveRef = useRef(node.asset.interactive) @@ -203,44 +324,62 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return () => useInteractive.getState().removeItem(node.id) }, [node.id]) - useMemo(() => { - scene.traverse((child) => { - if ((child as Mesh).isMesh) { - const mesh = child as Mesh - if (mesh.name === 'cutout') { - child.visible = false - return - } - - let hasGlass = false - - // Handle both single material and material array cases - if (Array.isArray(mesh.material)) { - mesh.material = mesh.material.map((mat) => - getMaterialForOriginal(mat, shading, textures, colorPreset), - ) - hasGlass = mesh.material.some((mat) => mat.name === 'glass') - - // Fix geometry groups that reference materialIndex beyond the material - // array length — this causes three-mesh-bvh to crash with - // "Cannot read properties of undefined (reading 'side')" - const matCount = mesh.material.length - if (mesh.geometry.groups.length > 0) { - for (const group of mesh.geometry.groups) { - if (group.materialIndex !== undefined && group.materialIndex >= matCount) { - group.materialIndex = 0 - } - } - } - } else { - mesh.material = getMaterialForOriginal(mesh.material, shading, textures, colorPreset) - hasGlass = mesh.material.name === 'glass' - } - mesh.castShadow = !hasGlass - mesh.receiveShadow = !hasGlass + useLayoutEffect(() => { + const root = ref.current + if (!root) return + + const meshEntries: { mesh: Mesh; captured: CapturedItemMaterialData }[] = [] + + root.traverse((child) => { + if (!(child as Mesh).isMesh) return + + const mesh = child as Mesh + if (mesh.name === 'cutout') { + child.visible = false } + + const captured = captureItemMeshMaterials(mesh) + if (mesh.name !== 'cutout') meshEntries.push({ mesh, captured }) }) - }, [scene, shading, textures, colorPreset]) + + const materialOptions = { + colorPreset, + nodeSlots: node.slots, + sceneMaterials, + shading, + textures, + } + + for (const { mesh, captured } of meshEntries) { + let hasGlass = false + + if (isCapturedMaterialArray(captured)) { + const nextMaterials = captured.authoredMaterials.map((authoredMaterial, index) => + resolveItemMaterial( + authoredMaterial, + captured.slotIds[index] ?? null, + captured.curatedRefs[index], + materialOptions, + ), + ) + mesh.material = nextMaterials + hasGlass = nextMaterials.some(isGlassMaterial) + clampGeometryGroups(mesh, nextMaterials.length) + } else { + const nextMaterial = resolveItemMaterial( + captured.authoredMaterials, + captured.slotIds, + captured.curatedRefs, + materialOptions, + ) + mesh.material = nextMaterial + hasGlass = isGlassMaterial(nextMaterial) + } + + mesh.castShadow = !hasGlass + mesh.receiveShadow = !hasGlass + } + }, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials]) const interactive = interactiveRef.current const animEffect = diff --git a/packages/nodes/src/roof/roof-materials.ts b/packages/nodes/src/roof/roof-materials.ts index b05cca1cc..940ae3bf1 100644 --- a/packages/nodes/src/roof/roof-materials.ts +++ b/packages/nodes/src/roof/roof-materials.ts @@ -3,6 +3,7 @@ import { createDefaultMaterial, createSurfaceRoleMaterial, type RenderShading, + resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import * as THREE from 'three' @@ -21,10 +22,12 @@ export function getRoofMaterials( const materials = textures ? [ - createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 0: Wall/Trim - createDefaultMaterial('#e5e5e5', 1, shading, THREE.FrontSide), // 1: Deck - createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 2: Interior - createDefaultMaterial('#e5e5e5', 0.9, shading, THREE.FrontSide), // 3: Shingle + // Mirrors getRoofMaterialArray's catalog defaults (wall/trim drywall, + // soft-white deck + soffit, terracotta shingle) for the no-parent path. + resolveSlotDefaultMaterial('library:concrete-drywall', shading), // 0: Wall/Trim + resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 1: Deck + resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior + resolveSlotDefaultMaterial('library:roof-terracottatiles', shading), // 3: Shingle ] : [ createSurfaceRoleMaterial('roof', colorPreset), diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts new file mode 100644 index 000000000..3e46173af --- /dev/null +++ b/packages/nodes/src/shared/slot-paint.ts @@ -0,0 +1,271 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type MaterialSchema, + type PaintCapability, + type PaintPreviewArgs, + type PaintResolveArgs, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + sceneRegistry, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import { type Material, type Mesh, type Object3D, Raycaster } from 'three' + +/** + * Shared paint capability for procedural kinds on the unified slot model + * (`node.slots: Record` + the shared scene-material + * palette) — the same data shape items derive from their GLB and the shelf + * declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which + * writes the legacy inline `node.material` copy the plan is retiring. + * + * The commit / resolve / effective-material logic is identical across kinds; + * only the slot-resolution from a pointer hit and the mesh preview differ, so + * those are injected per kind. + */ + +type SlotsNode = AnyNode & { slots?: Record } + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) return false + } + return true + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + const bKeys = Object.keys(bRecord) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false + if (!deepEqual(aRecord[key], bRecord[key])) return false + } + return true + } + return false +} + +function findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitSlotPaint( + node: SlotsNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + const nodeId = node.id as AnyNodeId + const state = useScene.getState() + const currentNode = (state.nodes[nodeId] as SlotsNode | undefined) ?? node + + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + + if (material === undefined && materialPreset === undefined) { + ref = undefined + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = findMatchingSceneMaterial(state.materials, material) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + const nextSlots = { ...(currentNode.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + + if (newSceneMaterial) { + // Creating the scene material and setting the slot ref are one logical + // edit, so apply both in a single `set` — zundo records one history entry, + // and one undo removes both the ref and its (now orphaned) material. + const sceneMaterial = newSceneMaterial + useScene.setState((s) => { + if (s.readOnly) return s + const node2 = s.nodes[nodeId] as SlotsNode | undefined + if (!node2) return s + return { + materials: { ...s.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...s.nodes, + [nodeId]: { ...node2, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +/** Preview material for a slot paint — mirrors the commit's resolution. */ +export function buildSlotPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + const shading = useViewer.getState().shading + if (materialPreset) { + const parsed = parseMaterialRef(materialPreset) + if (parsed?.kind === 'scene') { + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + return sceneMaterial ? createMaterial(sceneMaterial.material, shading) : null + } + return createMaterialFromPresetRef(materialPreset, shading) + } + if (material) return createMaterial(material, shading) + return null +} + +/** + * Preview for kinds whose meshes are produced by `def.geometry` and tagged + * with `userData.slotId` (+ `__fromGeometry`). Swaps every builder mesh whose + * slot matches `role`, leaving hosted-child meshes (which can carry a colliding + * `userData.slotId` from their own GLB) untouched. + */ +export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null { + const { role, root, material, materialPreset } = args + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const userData = mesh.userData as { slotId?: string | null; __fromGeometry?: boolean } + if (userData.__fromGeometry !== true) return + if (userData.slotId !== role) return + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} + +/** + * Preview for kinds whose meshes are built by a viewer system (window, door) + * and tagged with `userData.slotId` — no `__fromGeometry` marker and no hosted + * children to guard against, so it swaps every mesh whose slot matches `role`. + */ +export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | null { + const { role, root, material, materialPreset } = args + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + if ((mesh.userData as { slotId?: string | null }).slotId !== role) return + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} + +// Reused across calls — set from the pointer ray each time. +const subtreeRaycaster = new Raycaster() + +/** + * Resolve the slot for a kind whose paint hit lands on a proud opening proxy + * (door/window: a 1m-deep invisible cutout that wins the scene raycast over the + * wall in front of the recessed body) rather than the part itself. Re-raycasts + * the kind's OWN registered subtree (ignoring everything else) and returns the + * first tagged sub-mesh under the cursor; falls back to the direct hit's slot + * (e.g. a proud part the scene raycast hit directly). + */ +export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null { + const direct = (args.hitObject?.userData as { slotId?: string } | undefined)?.slotId + if (typeof direct === 'string') return direct + const root = sceneRegistry.nodes.get(args.node.id as AnyNodeId) + if (!root || !args.ray) return null + subtreeRaycaster.ray.copy(args.ray) + for (const hit of subtreeRaycaster.intersectObject(root, true)) { + const slot = (hit.object.userData as { slotId?: string }).slotId + if (typeof slot === 'string') return slot + } + return null +} + +export type SlotPaintConfig = { + /** Resolve the slot id for a pointer hit (`null` = not paintable here). */ + resolveRole: (args: PaintResolveArgs) => string | null + /** Apply a preview to the registered mesh subtree for `role`. */ + applyPreview: (args: PaintPreviewArgs) => (() => void) | null + /** + * Optional legacy fallback for the picker's current-value indicator — read + * when no `node.slots[role]` ref exists yet (e.g. a scene painted before the + * kind moved onto the slot model still carries inline `material`/`preset`). + */ + legacyEffective?: ( + node: AnyNode, + role: string, + ) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null +} + +export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { + return { + resolveRole: config.resolveRole, + buildPatch: ({ node, role, materialPreset }) => { + const slots = { ...((node as SlotsNode).slots ?? {}) } + if (materialPreset) slots[role] = materialPreset + else delete slots[role] + return { slots } as Partial + }, + commit: ({ node, role, material, materialPreset }) => + commitSlotPaint(node as SlotsNode, role, material, materialPreset), + applyPreview: config.applyPreview, + getEffectiveMaterial: ({ node, role }) => { + const ref = (node as SlotsNode).slots?.[role] + const parsed = parseMaterialRef(ref) + if (parsed) { + if (parsed.kind === 'library') return { material: undefined, materialPreset: ref } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + return config.legacyEffective?.(node, role) ?? null + }, + } +} diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index d42bd14f7..8819f0f97 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -4,8 +4,10 @@ import { buildShelfFloorplan } from './floorplan' import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances' import { shelfFloorplanMoveTarget } from './floorplan-move' import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry' +import { shelfPaint } from './paint' import { shelfParametrics } from './parametrics' import { ShelfNode } from './schema' +import { shelfSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 @@ -155,8 +157,8 @@ export const shelfDefinition: NodeDefinition = { withBottom: true, bracketStyle: 'minimal', // material / materialPreset left undefined — geometry falls back to - // `DEFAULT_SHELF_MATERIAL` (off-white), and paint mode writes the - // chosen catalog material into these fields. + // the per-slot off-white default, and slot paint mode writes chosen + // catalog materials into `slots`. }), capabilities: { @@ -183,6 +185,8 @@ export const shelfDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: shelfPaint, + slots: (n) => shelfSlots(n as ShelfNode), // Slab elevation lift via the generic `` — a // shelf sitting over a raised slab visually rests on top of it. floorPlaced: { @@ -233,6 +237,7 @@ export const shelfDefinition: NodeDefinition = { s.bracketStyle, s.material, s.materialPreset, + JSON.stringify(s.slots ?? null), ]) }, floorplan: buildShelfFloorplan, diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index c72eabfd5..d616d9425 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -109,7 +109,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, }, canCommit() { const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined - if (!live || live.type !== 'shelf') return false + if (live?.type !== 'shelf') return false return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) }, } diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index f71a3d470..d5d266c93 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -1,14 +1,15 @@ -import { getMaterialPresetByRef } from '@pascal-app/core' +import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' import { applyMaterialPresetToMaterials, createDefaultMaterial, createMaterial, - DEFAULT_SHELF_MATERIAL, type RenderShading, + resolveMaterialRef, } from '@pascal-app/viewer' -import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three' +import { BoxGeometry, Group, type Material, Mesh } from 'three' import { sanitizeShelfDimensions } from './dimensions' import type { ShelfNode } from './schema' +import { SHELF_SLOT_DEFAULT_COLOR, type ShelfSlotId } from './slots' /** * Pure shelf geometry builder. Takes a `ShelfNode` and returns a `Group` @@ -23,75 +24,104 @@ import type { ShelfNode } from './schema' * index arrays directly, and lets AI-generated nodes follow the same * shape with no editor-specific knowledge. * - * Materials: the kind exposes a single paintable surface via - * `node.material` / `node.materialPreset` — same shape walls / slabs / - * stairs use. When neither is set, every mesh shares the - * `DEFAULT_SHELF_MATERIAL` (off-white). When the user paints, the - * library preset's properties land on a cloned material here. The cache - * key includes the preset / material signature so paint changes - * invalidate without stomping unrelated shelves. + * Materials: the kind exposes per-slot paintable surfaces through + * `node.slots`, while `node.material` / `node.materialPreset` remain as + * legacy whole-shelf fallbacks. Every generated mesh is tagged with the + * slot it belongs to so paint mode can target shelves, frame, or back. * * Style dispatch lives at the top of the function; each style helper * mutates the same `group`. */ -type ShelfMaterial = Material & { - depthWrite: boolean -} +type ShelfSlotMaterials = Record -const shelfMaterialCache = new Map() - -function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material { - const cacheKey = JSON.stringify({ - shading, - material: node.material ?? null, - materialPreset: node.materialPreset ?? null, - }) - const cached = shelfMaterialCache.get(cacheKey) - if (cached) return cached - - const preset = getMaterialPresetByRef(node.materialPreset) - const material = preset - ? createDefaultMaterial('#ffffff', 0.5, shading) - : node.material - ? createMaterial(node.material, shading).clone() - : DEFAULT_SHELF_MATERIAL(shading).clone() - - if (preset) { - applyMaterialPresetToMaterials(material, preset) +function getShelfSlotMaterial( + node: ShelfNode, + slotId: ShelfSlotId, + materials: GeometryContext['materials'], + shading: RenderShading, +): Material { + const ref = node.slots?.[slotId] + if (ref) { + const resolved = resolveMaterialRef(ref, materials, shading) + if (resolved) return resolved + } + // Legacy whole-shelf paint applies to every slot when set (no per-slot override). + if (node.materialPreset) { + const preset = getMaterialPresetByRef(node.materialPreset) + if (preset) { + const base = createDefaultMaterial('#ffffff', 0.5, shading) + applyMaterialPresetToMaterials(base, preset) + return base + } } + if (node.material) return createMaterial(node.material, shading) + return createDefaultMaterial(SHELF_SLOT_DEFAULT_COLOR, 0.9, shading) +} + +function stampShelfSlot(mesh: Mesh, slotId: ShelfSlotId): Mesh { + mesh.userData.slotId = slotId + return mesh +} - const shelfMaterial = material as ShelfMaterial - shelfMaterial.side = FrontSide - shelfMaterial.depthWrite = true - shelfMaterial.needsUpdate = true +// A board's front/back faces land on the frame's outer faces (posts / back panel) +// — coplanar surfaces the depth buffer can't separate, which flickers as z-fighting. +// Recess 1mm so the board sits just inside: the meshes still overlap (no gap), but +// no faces are coplanar. Depth is always recessed (boards reach into the back panel +// / posts). Width is recessed only at call sites where boards span OVER posts +// (open-rack / no-sides bookshelf); boards that ABUT side panels keep full width so +// they meet the sides flush — abutting faces are back-to-back and never fight. +const BOARD_INSET = 0.001 + +// Frame members that pass under the top board (dividers / back / corner posts) reach +// y=unitHeight, coplanar with the top board's top face → z-fighting. Drop their top 1mm so +// the board cleanly caps them; their bottom stays on the floor. +const FRAME_TOP_INSET = 0.001 + +function cappedFrameY(unitHeight: number): { height: number; centerY: number } { + const height = Math.max(unitHeight - FRAME_TOP_INSET, 0.001) + return { height, centerY: height / 2 } +} - shelfMaterialCache.set(cacheKey, material) - return material +function boardGeometry( + width: number, + thickness: number, + depth: number, + insetWidth = false, +): BoxGeometry { + return new BoxGeometry( + insetWidth ? Math.max(width - 2 * BOARD_INSET, 0.001) : width, + thickness, + Math.max(depth - 2 * BOARD_INSET, 0.001), + ) } export function buildShelfGeometry( rawNode: ShelfNode, - _ctx?: unknown, + ctx?: GeometryContext, shading: RenderShading = 'rendered', ): Group { const node = sanitizeShelfDimensions(rawNode) const group = new Group() group.name = 'shelf-geometry' - const material = getShelfMaterial(node, shading) + const materials: ShelfSlotMaterials = { + shelves: getShelfSlotMaterial(node, 'shelves', ctx?.materials, shading), + frame: getShelfSlotMaterial(node, 'frame', ctx?.materials, shading), + back: getShelfSlotMaterial(node, 'back', ctx?.materials, shading), + } switch (node.style) { case 'wall-shelf': - buildWallShelf(group, node, material) + buildWallShelf(group, node, materials) break case 'bookshelf': - buildBookshelf(group, node, material) + buildBookshelf(group, node, materials) break case 'open-rack': - buildOpenRack(group, node, material) + buildOpenRack(group, node, materials) break case 'cubby': - buildCubby(group, node, material) + buildCubby(group, node, materials) break } @@ -112,9 +142,12 @@ export function buildShelfGeometry( * evenly-spaced boards from `height/rows` up to `height`. Brackets * span from floor to the topmost board. */ -function buildWallShelf(group: Group, node: ShelfNode, material: Material) { +function buildWallShelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { for (const y of boardCenterYs(node)) { - const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material) + const board = stampShelfSlot( + new Mesh(boardGeometry(node.width, node.thickness, node.depth), materials.shelves), + 'shelves', + ) board.name = `shelf-board-${boardRowIndex(node, y)}` board.position.set(0, y, 0) group.add(board) @@ -131,7 +164,10 @@ function buildWallShelf(group: Group, node: ShelfNode, material: Material) { const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7 for (const sign of [-1, 1] as const) { - const bracket = new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), material) + const bracket = stampShelfSlot( + new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), materials.frame), + 'frame', + ) bracket.name = `shelf-bracket-${sign === -1 ? 'left' : 'right'}` bracket.position.set(sign * (node.width / 2 - inset), bracketHeight / 2, 0) group.add(bracket) @@ -144,20 +180,30 @@ function buildWallShelf(group: Group, node: ShelfNode, material: Material) { * `withSides === false`, side panels become slim corner posts (a rack * silhouette). */ -function buildBookshelf(group: Group, node: ShelfNode, material: Material) { +function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { const unitHeight = node.height + node.thickness const innerWidth = node.withSides ? node.width - 2 * node.thickness : node.width - // Top + bottom + intermediate boards + // Top + bottom + intermediate boards. No sides => boards span over corner + // posts, so inset their width too; with sides they abut the panels (flush). for (const y of boardCenterYs(node)) { - const board = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + const board = stampShelfSlot( + new Mesh( + boardGeometry(innerWidth, node.thickness, node.depth, !node.withSides), + materials.shelves, + ), + 'shelves', + ) board.name = `shelf-board-${boardRowIndex(node, y)}` board.position.set(0, y, 0) group.add(board) } if (node.withBottom) { - const bottom = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + const bottom = stampShelfSlot( + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) bottom.name = 'shelf-board-bottom' bottom.position.set(0, node.thickness / 2, 0) group.add(bottom) @@ -166,30 +212,48 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) { // Side panels (or corner posts) — span the full unit height. if (node.withSides) { for (const sign of [-1, 1] as const) { - const side = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + const side = stampShelfSlot( + new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame), + 'frame', + ) side.name = `shelf-side-${sign === -1 ? 'left' : 'right'}` side.position.set(sign * (node.width / 2 - node.thickness / 2), unitHeight / 2, 0) group.add(side) } } else { - addCornerPosts(group, node, material, unitHeight, 'rack') + addCornerPosts(group, node, materials.frame, unitHeight, 'rack') } if (node.withBack) { - const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material) + const fy = cappedFrameY(unitHeight) + const back = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, fy.height, node.thickness), materials.back), + 'back', + ) back.name = 'shelf-back' - back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) + back.position.set(0, fy.centerY, -(node.depth / 2 - node.thickness / 2)) group.add(back) } // Vertical dividers between columns if (node.columns > 1) { + const fy = cappedFrameY(unitHeight) const colStep = innerWidth / node.columns for (let c = 1; c < node.columns; c++) { const x = -innerWidth / 2 + c * colStep - const divider = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + const divider = stampShelfSlot( + // A full-height divider crosses the shelves, so its depth must sit + // INSIDE the boards' (already recessed) depth: embedded at each crossing + // (the board occludes it — no coplanar fight) and tucked inside the back + // panel, rather than proud at the front / coplanar with the back. + new Mesh( + new BoxGeometry(node.thickness, fy.height, node.depth - 4 * BOARD_INSET), + materials.frame, + ), + 'frame', + ) divider.name = `shelf-divider-col-${c}` - divider.position.set(x, unitHeight / 2, 0) + divider.position.set(x, fy.centerY, 0) group.add(divider) } } @@ -200,26 +264,32 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) { * X-brace on the back face for stability. `withSides` / `bracketStyle` * are ignored (the rack defines its own posts). */ -function buildOpenRack(group: Group, node: ShelfNode, material: Material) { +function buildOpenRack(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { const unitHeight = node.height + node.thickness const innerWidth = node.width const boardThickness = Math.max(0.02, node.thickness * 0.8) for (const y of boardCenterYs(node)) { - const board = new Mesh(new BoxGeometry(innerWidth, boardThickness, node.depth), material) + const board = stampShelfSlot( + new Mesh(boardGeometry(innerWidth, boardThickness, node.depth, true), materials.shelves), + 'shelves', + ) board.name = `shelf-board-${boardRowIndex(node, y)}` board.position.set(0, y, 0) group.add(board) } - addCornerPosts(group, node, material, unitHeight, 'rack') + addCornerPosts(group, node, materials.frame, unitHeight, 'rack') if (node.withBack) { const braceThickness = Math.max(0.015, node.thickness * 0.6) for (const y of [boardThickness, unitHeight - boardThickness] as const) { - const brace = new Mesh( - new BoxGeometry(node.width - braceThickness * 2, braceThickness, braceThickness), - material, + const brace = stampShelfSlot( + new Mesh( + new BoxGeometry(node.width - braceThickness * 2, braceThickness, braceThickness), + materials.frame, + ), + 'frame', ) brace.name = `shelf-brace-h-${y < unitHeight / 2 ? 'bottom' : 'top'}` brace.position.set(0, y, -(node.depth / 2 - braceThickness / 2)) @@ -233,49 +303,69 @@ function buildOpenRack(group: Group, node: ShelfNode, material: Material) { * boards + vertical dividers. `withBack` / `withSides` are forced on * because the cubby shape requires them. */ -function buildCubby(group: Group, node: ShelfNode, material: Material) { +function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { const unitHeight = node.height + node.thickness const innerWidth = node.width - 2 * node.thickness for (const y of boardCenterYs(node)) { - const board = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + const board = stampShelfSlot( + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) board.name = `shelf-board-${boardRowIndex(node, y)}` board.position.set(0, y, 0) group.add(board) } if (node.withBottom) { - const bottom = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + const bottom = stampShelfSlot( + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) bottom.name = 'shelf-board-bottom' bottom.position.set(0, node.thickness / 2, 0) group.add(bottom) } for (const sign of [-1, 1] as const) { - const side = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + const side = stampShelfSlot( + new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame), + 'frame', + ) side.name = `shelf-side-${sign === -1 ? 'left' : 'right'}` side.position.set(sign * (node.width / 2 - node.thickness / 2), unitHeight / 2, 0) group.add(side) } - const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material) + const fy = cappedFrameY(unitHeight) + const back = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, fy.height, node.thickness), materials.back), + 'back', + ) back.name = 'shelf-back' - back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) + back.position.set(0, fy.centerY, -(node.depth / 2 - node.thickness / 2)) group.add(back) if (node.columns > 1) { const colStep = innerWidth / node.columns const rowStep = node.height / node.rows for (let r = 0; r < node.rows; r++) { - const cellBottomY = node.thickness + r * rowStep + // Without a bottom board the lowest cell opens onto the floor, so its + // divider must reach y=0 rather than rest on a (missing) board top. + const cellBottomY = r === 0 && !node.withBottom ? 0 : node.thickness + r * rowStep const cellTopY = node.thickness + (r + 1) * rowStep const dividerHeight = cellTopY - cellBottomY - node.thickness if (dividerHeight <= 0) continue for (let c = 1; c < node.columns; c++) { const x = -innerWidth / 2 + c * colStep - const divider = new Mesh( - new BoxGeometry(node.thickness, dividerHeight, node.depth), - material, + const divider = stampShelfSlot( + // Same depth recess as the boards: the divider sits flush with the + // shelf fronts (not proud) and its back tucks inside the back panel, + // so it neither overflows the boards at the front nor z-fights the + // back panel down the centre. Height is flush (the board faces it + // meets top/bottom are back-to-back, so they don't fight). + new Mesh(boardGeometry(node.thickness, dividerHeight, node.depth), materials.frame), + 'frame', ) divider.name = `shelf-divider-${r}-${c}` divider.position.set(x, cellBottomY + dividerHeight / 2, 0) @@ -319,16 +409,20 @@ function addCornerPosts( unitHeight: number, postStyle: 'rack' | 'leg', ) { + const fy = cappedFrameY(unitHeight) const postThickness = postStyle === 'rack' ? Math.max(0.025, node.thickness * 1.5) : Math.max(0.02, node.thickness) const inset = postThickness / 2 for (const xSign of [-1, 1] as const) { for (const zSign of [-1, 1] as const) { - const post = new Mesh(new BoxGeometry(postThickness, unitHeight, postThickness), material) + const post = stampShelfSlot( + new Mesh(new BoxGeometry(postThickness, fy.height, postThickness), material), + 'frame', + ) post.name = `shelf-post-${xSign === -1 ? 'l' : 'r'}${zSign === -1 ? 'b' : 'f'}` post.position.set( xSign * (node.width / 2 - inset), - unitHeight / 2, + fy.centerY, zSign * (node.depth / 2 - inset), ) group.add(post) diff --git a/packages/nodes/src/shelf/paint.ts b/packages/nodes/src/shelf/paint.ts new file mode 100644 index 000000000..e6c8a5642 --- /dev/null +++ b/packages/nodes/src/shelf/paint.ts @@ -0,0 +1,218 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type MaterialSchema, + type PaintCapability, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + type ShelfNode, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import type { Material, Mesh } from 'three' + +type ShelfSlotUserData = { + slotId?: string | null +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) return false + } + return true + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + const bKeys = Object.keys(bRecord) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false + if (!deepEqual(aRecord[key], bRecord[key])) return false + } + return true + } + return false +} + +function resolveShelfSlotId(args: { hitObject?: { userData?: ShelfSlotUserData } }): string | null { + const slotId = args.hitObject?.userData?.slotId + return typeof slotId === 'string' ? slotId : null +} + +function buildShelfSlotsPatch( + node: ShelfNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const slots = { ...(node.slots ?? {}) } + if (material === undefined && materialPreset === undefined) { + delete slots[role] + return { slots } + } + if (materialPreset) { + slots[role] = materialPreset + return { slots } + } + return { slots } +} + +function findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitNewSceneMaterialAndSlots( + nodeId: AnyNodeId, + nextSlots: ShelfNode['slots'], + sceneMaterial: SceneMaterial, +): void { + // Creating the scene material and setting the slot ref are one logical + // edit, so apply both in a single `set` — zundo records one history entry, + // and one undo removes both the ref and its (now orphaned) material. + useScene.setState((state) => { + if (state.readOnly) return state + const currentNode = state.nodes[nodeId] + if (currentNode?.type !== 'shelf') return state + return { + materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...state.nodes, + [nodeId]: { ...currentNode, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) +} + +function commitShelfPaint( + node: ShelfNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + const nodeId = node.id as AnyNodeId + const state = useScene.getState() + const currentNode = (state.nodes[nodeId] as ShelfNode | undefined) ?? node + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + + if (material === undefined && materialPreset === undefined) { + ref = undefined + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = findMatchingSceneMaterial(state.materials, material) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + const nextSlots = { ...(currentNode.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + + if (newSceneMaterial) { + commitNewSceneMaterialAndSlots(nodeId, nextSlots, newSceneMaterial) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +function buildPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + const shading = useViewer.getState().shading + if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading) + if (material) return createMaterial(material, shading) + return null +} + +function applyShelfPreview( + role: string, + root: import('three').Object3D, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): (() => void) | null { + const previewMaterial = buildPreviewMaterial(material, materialPreset) + if (!previewMaterial) return () => {} + + const restores: Array<() => void> = [] + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const userData = mesh.userData as ShelfSlotUserData & { __fromGeometry?: boolean } + // Only the shelf's own builder meshes — never hosted item children, whose + // GLB meshes can carry a colliding `userData.slotId` (slot_frame, etc.). + if (userData.__fromGeometry !== true) return + if (userData.slotId !== role) return + + const previous = mesh.material + mesh.material = previewMaterial + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } +} + +export const shelfPaint: PaintCapability = { + resolveRole: ({ hitObject }) => + resolveShelfSlotId({ hitObject: hitObject as { userData?: ShelfSlotUserData } }), + buildPatch: ({ node, role, material, materialPreset }) => + buildShelfSlotsPatch(node as ShelfNode, role, material, materialPreset) as Partial, + commit: ({ node, role, material, materialPreset }) => + commitShelfPaint(node as ShelfNode, role, material, materialPreset), + applyPreview: ({ role, root, material, materialPreset }) => + applyShelfPreview(role, root, material, materialPreset), + getEffectiveMaterial: ({ node, role }) => { + const shelf = node as ShelfNode + const parsed = parseMaterialRef(shelf.slots?.[role]) + if (parsed) { + if (parsed.kind === 'library') { + return { material: undefined, materialPreset: shelf.slots?.[role] } + } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + // No (or dangling) slot ref — surface the legacy whole-shelf paint the + // geometry builder still falls back to, so the picker matches what renders. + if (shelf.materialPreset || shelf.material) { + return { material: shelf.material, materialPreset: shelf.materialPreset } + } + return null + }, +} diff --git a/packages/nodes/src/shelf/preview.tsx b/packages/nodes/src/shelf/preview.tsx index e069a065b..f9fa83209 100644 --- a/packages/nodes/src/shelf/preview.tsx +++ b/packages/nodes/src/shelf/preview.tsx @@ -13,11 +13,9 @@ import type { ShelfNode } from './schema' * then walks the result, **clones** each mesh's material, and mutates * the clone for a translucent ghost. * - * Cloning is non-negotiable: `getShelfMaterial` caches the default - * material instance in a module-scoped map keyed on - * `material` / `materialPreset`, so every unpainted shelf in the scene - * shares the same material. Mutating `mat.transparent = true` here - * would leak into every committed shelf and render them all see-through. + * Cloning is non-negotiable: shelf geometry may receive materials from + * shared viewer caches, so mutating `mat.transparent = true` here would + * leak into committed shelves using the same material. * * Building the full geometry tree per-frame would be wasteful, so we * memoize the group + dispose the per-mesh material clones on unmount. @@ -44,9 +42,9 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => { ;(obj as unknown as { raycast: () => void }).raycast = () => {} // `Mesh.material` is typed as `Material | Material[]` upstream; - // every shelf board carries a material from - // `getShelfMaterial`. Access through a structural cast keeps the - // assignment well-typed without depending on the Mesh union. + // every shelf board carries a material from the geometry builder. + // Access through a structural cast keeps the assignment well-typed + // without depending on the Mesh union. const mesh = obj as { material?: Material | Material[] } @@ -70,8 +68,8 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => { return () => { // Dispose only the clones we made — never the shared cached - // material returned by `getShelfMaterial`, which other shelves in - // the scene still reference. Geometry is left alone for the same + // material returned by the builder, which other shelves in the + // scene may still reference. Geometry is left alone for the same // reason; the builder may move to a cached strategy in future. for (const c of cloned) c.dispose() built.traverse((obj) => { diff --git a/packages/nodes/src/shelf/slots.ts b/packages/nodes/src/shelf/slots.ts new file mode 100644 index 000000000..411eed965 --- /dev/null +++ b/packages/nodes/src/shelf/slots.ts @@ -0,0 +1,35 @@ +import type { SlotDeclaration } from '@pascal-app/core' +import type { ShelfNode } from './schema' + +export type ShelfSlotId = 'shelves' | 'frame' | 'back' + +// Visual parity with the retired DEFAULT_SHELF_MATERIAL (off-white). +export const SHELF_SLOT_DEFAULT_COLOR = '#ffffff' + +/** Map a builder mesh name to its slot id (null = not a paintable shelf part). */ +export function shelfSlotIdForMeshName(name: string): ShelfSlotId | null { + if (name.startsWith('shelf-board')) return 'shelves' + if (name === 'shelf-back') return 'back' + if ( + name.startsWith('shelf-side') || + name.startsWith('shelf-post') || + name.startsWith('shelf-divider') || + name.startsWith('shelf-bracket') || + name.startsWith('shelf-brace') + ) { + return 'frame' + } + return null +} + +/** Which slots a given shelf actually exposes (depends on style/flags). */ +export function shelfSlots(node: ShelfNode): SlotDeclaration[] { + const slots: SlotDeclaration[] = [ + { slotId: 'shelves', label: 'Shelves', default: SHELF_SLOT_DEFAULT_COLOR }, + ] + const hasFrame = !(node.style === 'wall-shelf' && node.bracketStyle === 'hidden') + if (hasFrame) slots.push({ slotId: 'frame', label: 'Frame', default: SHELF_SLOT_DEFAULT_COLOR }) + const hasBack = node.style === 'cubby' || (node.style === 'bookshelf' && node.withBack) + if (hasBack) slots.push({ slotId: 'back', label: 'Back', default: SHELF_SLOT_DEFAULT_COLOR }) + return slots +} diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index d70a736b2..d8f698bc9 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -12,8 +12,10 @@ import { } from './floorplan-affordances' import { slabFloorplanMoveTarget } from './floorplan-move' import { buildSlabGeometry } from './geometry' +import { slabPaint } from './paint' import { slabParametrics } from './parametrics' import { SlabNode } from './schema' +import { slabSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_SLAB_ELEVATION = 0.02 @@ -155,6 +157,10 @@ export const slabDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + // Unified slot model: one paintable floor surface with a declared default, + // painted through the registry `capabilities.paint` dispatch like the shelf. + slots: () => slabSlots(), + paint: slabPaint, }, relations: { diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index dd1a68080..6b3c241db 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -1,25 +1,37 @@ -import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' +import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' import { applyMaterialPresetToMaterials, type ColorPreset, createDefaultMaterial, createMaterial, createSurfaceRoleMaterial, - DEFAULT_SLAB_MATERIAL, generateSlabGeometry, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, } from '@pascal-app/viewer' -import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' +import { + BufferGeometry, + Float32BufferAttribute, + FrontSide, + Group, + type Material, + Mesh, + type Texture, + Vector3, +} from 'three' +import { SLAB_SIDE_SLOT_DEFAULT, SLAB_TOP_SLOT_DEFAULT, type SlabSlotId } from './slots' /** * Stage B builder for slab. Reuses `generateSlabGeometry` (pure * triangulation + hole CSG from viewer) and the same material cache * pattern the legacy slab renderer used. * - * Materials are cached by `{material, materialPreset}` signature so - * slabs sharing settings share the GPU resource. Cached entry mutation - * (preset apply) is preserved — async texture loads still update the - * rendered material after re-mount. + * Materials follow the unified slot model: the single `surface` slot resolves + * `node.slots.surface` (a shared scene material or `library:` finish) → the + * legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) → + * the declared slot default colour. Textures-off collapses to the themed + * `floor` role — the guaranteed monochrome escape hatch. */ type SlabMaterial = Material & { alphaMap?: Texture | null @@ -30,24 +42,98 @@ type SlabMaterial = Material & { const slabMaterialCache = new Map() -function getSlabMaterial( +function getSlabSlotMaterial( node: SlabNode, + slotId: SlabSlotId, shading: RenderShading, textures: boolean, colorPreset: ColorPreset, - sceneTheme?: string, + sceneTheme: string | undefined, + sceneMaterials: GeometryContext['materials'], ): Material { - // Untextured slabs (and everything in textures-off mode) take the themed - // 'floor' role colour. createSurfaceRoleMaterial returns a shared cached - // material, so it is returned as-is without the mutation below. - // FrontSide — DoubleSide on the role material's NodeMaterial poisons the - // MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5). - // Slab side faces still render correctly because `generateSlabGeometry` - // produces outward-facing normals on the top, bottom, and perimeter. - if (!textures || (!node.materialPreset && !node.material)) { + // Textures-off mode takes the themed 'floor' role colour for every face — the + // guaranteed escape hatch, independent of any slot override. FrontSide — + // DoubleSide on the role material's NodeMaterial poisons the MRT scene pass + // (see `materials.ts` line 77 / glazing fix 9400f1c5). Slab side faces still + // render correctly because `generateSlabGeometry` emits outward-facing normals. + if (!textures) { return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme) } + // Unified slot override — shared scene material or catalog `library:` finish. + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) + if (resolved) return resolved + } + + // Legacy inline material / preset (pre-slot-model scenes) applied to the whole + // slab — map it onto the top face only; sides take their own default. + if (slotId === 'surface' && (node.materialPreset || node.material)) { + return getLegacySlabMaterial(node, shading) + } + + // Declared slot default — a catalog `library:` finish or a flat colour. + const slotDefault = slotId === 'side' ? SLAB_SIDE_SLOT_DEFAULT : SLAB_TOP_SLOT_DEFAULT + return resolveSlotDefaultMaterial(slotDefault, shading, 0.8) +} + +// Split the merged slab buffer into top-facing (floor) and everything-else +// (vertical walls + underside) sub-geometries by per-triangle face normal, so +// the two paintable slots get distinct materials + raycast tags. De-indexes +// into per-face triangles (slabs are flat-shaded, so no shared-vertex seams). +function splitSlabFacesByFacing(geometry: BufferGeometry): { + top: BufferGeometry + side: BufferGeometry +} { + const position = geometry.getAttribute('position') + const uv = geometry.getAttribute('uv') + const index = geometry.getIndex() + const triangleCount = index ? index.count / 3 : position.count / 3 + + const top = { pos: [] as number[], uv: [] as number[] } + const side = { pos: [] as number[], uv: [] as number[] } + const a = new Vector3() + const b = new Vector3() + const c = new Vector3() + const ab = new Vector3() + const ac = new Vector3() + const normal = new Vector3() + + for (let t = 0; t < triangleCount; t += 1) { + const i0 = index ? index.getX(t * 3) : t * 3 + const i1 = index ? index.getX(t * 3 + 1) : t * 3 + 1 + const i2 = index ? index.getX(t * 3 + 2) : t * 3 + 2 + a.fromBufferAttribute(position, i0) + b.fromBufferAttribute(position, i1) + c.fromBufferAttribute(position, i2) + ab.subVectors(b, a) + ac.subVectors(c, a) + normal.crossVectors(ab, ac) + const lengthSq = normal.lengthSq() + const isTop = lengthSq > 1e-12 && normal.y / Math.sqrt(lengthSq) > 0.5 + const target = isTop ? top : side + for (const i of [i0, i1, i2]) { + target.pos.push(position.getX(i), position.getY(i), position.getZ(i)) + if (uv) target.uv.push(uv.getX(i), uv.getY(i)) + } + } + + const build = (data: { pos: number[]; uv: number[] }) => { + const geo = new BufferGeometry() + geo.setAttribute('position', new Float32BufferAttribute(data.pos, 3)) + if (data.uv.length > 0) geo.setAttribute('uv', new Float32BufferAttribute(data.uv, 2)) + geo.computeVertexNormals() + return geo + } + + return { top: build(top), side: build(side) } +} + +function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material { + // Cached by `{material, materialPreset}` signature so slabs sharing settings + // share the GPU resource; cached entry mutation (preset apply) is preserved + // so async texture loads still update the rendered material after re-mount. const cacheKey = JSON.stringify({ shading, material: node.material ?? null, @@ -61,7 +147,7 @@ function getSlabMaterial( ? createDefaultMaterial('#ffffff', 0.5, shading) : node.material ? createMaterial(node.material, shading).clone() - : DEFAULT_SLAB_MATERIAL(shading).clone() + : createDefaultMaterial('#e5e5e5', 0.8, shading) if (preset) { applyMaterialPresetToMaterials(material, preset) @@ -84,20 +170,39 @@ function getSlabMaterial( export function buildSlabGeometry( node: SlabNode, - _ctx?: unknown, + ctx?: GeometryContext, shading: RenderShading = 'rendered', textures = true, colorPreset: ColorPreset = 'clay', sceneTheme?: string, ): Group { const group = new Group() - const geometry = generateSlabGeometry(node) - const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme) - const mesh = new Mesh(geometry, material) - mesh.castShadow = true - mesh.receiveShadow = true + const merged = generateSlabGeometry(node) + const { top, side } = splitSlabFacesByFacing(merged) + merged.dispose() + const elevation = node.elevation ?? 0.05 - if (elevation < 0) mesh.position.y = elevation - group.add(mesh) + // One mesh per slot, each tagged with its slot id so the unified slot paint + // resolves the hit (`resolveRole` reads `userData.slotId`) and previews it. + for (const [slotId, geometry] of [ + ['surface', top], + ['side', side], + ] as const) { + const material = getSlabSlotMaterial( + node, + slotId, + shading, + textures, + colorPreset, + sceneTheme, + ctx?.materials, + ) + const mesh = new Mesh(geometry, material) + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.slotId = slotId + if (elevation < 0) mesh.position.y = elevation + group.add(mesh) + } return group } diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts new file mode 100644 index 000000000..628c75cc4 --- /dev/null +++ b/packages/nodes/src/slab/paint.ts @@ -0,0 +1,26 @@ +import type { AnyNode, SlabNode } from '@pascal-app/core' +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +/** + * Slab paint on the unified slot model. A slab exposes two faces — `surface` + * (top) and `side` (walls + underside) — each its own mesh tagged with + * `userData.slotId`, so the clicked face resolves to its slot; commit writes + * `node.slots[slotId]` (a shared scene-material or `library:` ref) like the shelf. + */ +export const slabPaint = createSlotPaintCapability({ + resolveRole: ({ hitObject }) => { + const slotId = (hitObject?.userData as { slotId?: string } | undefined)?.slotId + return slotId === 'side' ? 'side' : 'surface' + }, + applyPreview: previewGeometrySlot, + // Legacy inline material applied to the whole slab → maps onto the top only; + // the side picker shows its own default. + legacyEffective: (node: AnyNode, role: string) => { + if (role !== 'surface') return null + const slab = node as SlabNode + if (slab.materialPreset || slab.material) { + return { material: slab.material, materialPreset: slab.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/slab/slots.ts b/packages/nodes/src/slab/slots.ts new file mode 100644 index 000000000..bdb2c8082 --- /dev/null +++ b/packages/nodes/src/slab/slots.ts @@ -0,0 +1,25 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type SlabSlotId = 'surface' | 'side' + +// Declared default appearances for an unpainted slab in colored mode — a +// catalog `library:` finish or a `#rrggbb` colour. Textures-off collapses +// both to the themed floor role (the escape hatch). +// +// `surface` (top face) keeps the wood floor default and the slot id used before +// the top/side split, so existing painted slabs keep their floor finish. `side` +// (walls + underside) defaults to a light grey so a slab's edges read as a +// distinct trim rather than wood end-grain. +export const SLAB_TOP_SLOT_DEFAULT = 'library:wood-woodplank48' +export const SLAB_SIDE_SLOT_DEFAULT = '#cccccc' + +/** + * A slab exposes two paintable faces: the top floor surface and its sides + * (vertical walls + underside). + */ +export function slabSlots(): SlotDeclaration[] { + return [ + { slotId: 'surface', label: 'Top', default: SLAB_TOP_SLOT_DEFAULT }, + { slotId: 'side', label: 'Sides', default: SLAB_SIDE_SLOT_DEFAULT }, + ] +} diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index a6bf6218b..200b86821 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -406,8 +406,10 @@ import { stairRotateAffordance, } from './floorplan-affordances' import { stairFloorplanMoveTarget } from './floorplan-move' +import { stairPaint } from './paint' import { stairParametrics } from './parametrics' import { StairNode } from './schema' +import { stairSlots } from './slots' /** * Stair — Stage A. Composite node like roof: owns overall framing, @@ -444,6 +446,8 @@ export const stairDefinition: NodeDefinition = { footprints: (node, ctx) => ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [], }, + slots: (node) => stairSlots(node as StairNodeType), + paint: stairPaint, }, // Bespoke move shared with roof / roof-segment / stair-segment via diff --git a/packages/nodes/src/stair/paint.ts b/packages/nodes/src/stair/paint.ts new file mode 100644 index 000000000..09b3de8bc --- /dev/null +++ b/packages/nodes/src/stair/paint.ts @@ -0,0 +1,100 @@ +import type { AnyNode, PaintPreviewArgs, PaintResolveArgs, StairNode } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import type { StairSlotId } from './slots' + +function isStairSlotId(value: unknown): value is StairSlotId { + return value === 'treads' || value === 'body' || value === 'railing' +} + +function resolveStairPaintRole(args: PaintResolveArgs): StairSlotId | null { + const userData = args.hitObject?.userData as { slotId?: unknown; slotIds?: unknown } | undefined + + if (isStairSlotId(userData?.slotId)) { + return userData.slotId + } + + if (Array.isArray(userData?.slotIds)) { + const slotId = userData.slotIds[args.materialIndex ?? 0] + return isStairSlotId(slotId) ? slotId : null + } + + return null +} + +function previewStairSlot(args: PaintPreviewArgs): (() => void) | null { + const { role, root, material, materialPreset } = args + if (!isStairSlotId(role)) return null + + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + + const userData = mesh.userData as { slotId?: unknown; slotIds?: unknown } + if (userData.slotId === role) { + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + return + } + + if (!Array.isArray(userData.slotIds)) return + const materialIndex = userData.slotIds.findIndex((slotId) => slotId === role) + if (materialIndex < 0) return + if (!Array.isArray(mesh.material)) return + + const previous = mesh.material + const next = previous.slice() + next[materialIndex] = preview + mesh.material = next + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} + +function legacyEffective(node: AnyNode, role: string) { + if (!isStairSlotId(role)) return null + + const stair = node as StairNode + const perSlot = + role === 'treads' + ? { material: stair.treadMaterial, materialPreset: stair.treadMaterialPreset } + : role === 'body' + ? { material: stair.sideMaterial, materialPreset: stair.sideMaterialPreset } + : { material: stair.railingMaterial, materialPreset: stair.railingMaterialPreset } + + if (perSlot.material !== undefined || typeof perSlot.materialPreset === 'string') { + return { + material: perSlot.material, + materialPreset: + typeof perSlot.materialPreset === 'string' ? perSlot.materialPreset : undefined, + } + } + + if (stair.material !== undefined || typeof stair.materialPreset === 'string') { + return { + material: stair.material, + materialPreset: typeof stair.materialPreset === 'string' ? stair.materialPreset : undefined, + } + } + + return null +} + +export const stairPaint = createSlotPaintCapability({ + resolveRole: resolveStairPaintRole, + applyPreview: previewStairSlot, + legacyEffective, +}) diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index ec928e186..c038c9194 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -9,13 +9,11 @@ import { useScene, } from '@pascal-app/core' import { - createMaterial, - createMaterialFromPresetRef, - createSurfaceRoleMaterial, - DEFAULT_STAIR_MATERIAL, getStairBodyMaterials, getStairRailingMaterial, NodeRenderer, + resolveMaterialRef, + resolveSlotDefaultMaterial, type StairBodyMaterials, useNodeEvents, useViewer, @@ -23,6 +21,12 @@ import { import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { + STAIR_BODY_SLOT_DEFAULT, + STAIR_RAILING_SLOT_DEFAULT, + STAIR_TREADS_SLOT_DEFAULT, + type StairSlotId, +} from './slots' type SegmentTransform = { position: [number, number, number] @@ -78,36 +82,57 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) + const sceneMaterials = useScene((s) => s.materials) - const material = useMemo(() => { - if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset) - const presetMaterial = createMaterialFromPresetRef(node.materialPreset, shading) - if (presetMaterial) return presetMaterial - const mat = node.material - if (!mat) return DEFAULT_STAIR_MATERIAL(shading) - return createMaterial(mat, shading) - }, [ - shading, - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - textures, - colorPreset, - ]) - - const straightBodyMaterials = useMemo( + const baseBodyMaterials = useMemo( () => getStairBodyMaterials(node, shading, textures, colorPreset), [node, shading, textures, colorPreset], ) - const railingMaterial = useMemo( + const bodyMaterials = useMemo( + () => [ + resolveStairSlotMaterial( + node, + 'treads', + STAIR_TREADS_SLOT_DEFAULT, + baseBodyMaterials[STAIR_TREAD_MATERIAL_INDEX], + sceneMaterials, + shading, + textures, + ), + resolveStairSlotMaterial( + node, + 'body', + STAIR_BODY_SLOT_DEFAULT, + baseBodyMaterials[STAIR_SIDE_MATERIAL_INDEX], + sceneMaterials, + shading, + textures, + ), + ], + [baseBodyMaterials, node, sceneMaterials, shading, textures], + ) + + const baseRailingMaterial = useMemo( () => getStairRailingMaterial(node, shading, textures, colorPreset), [node, shading, textures, colorPreset], ) - // 2 groups map 1:1 to the stair body's 2-material array (body + tread). + const railingMaterial = useMemo( + () => + resolveStairSlotMaterial( + node, + 'railing', + STAIR_RAILING_SLOT_DEFAULT, + baseRailingMaterial, + sceneMaterials, + shading, + textures, + ), + [baseRailingMaterial, node, sceneMaterials, shading, textures], + ) + + // 2 groups map 1:1 to the stair body's 2-material array (treads + body). const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), []) useEffect(() => { @@ -129,14 +154,13 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { ) : null} - {isSegmentBasedStair ? null : ( - - )} + {isSegmentBasedStair ? null : } {isSegmentBasedStair ? ( @@ -235,6 +259,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. position={[point[0], point[1] + railHeight / 2, point[2]]} receiveShadow scale={[balusterRadius, railHeight, balusterRadius]} + userData={STAIR_RAILING_SLOT_USER_DATA} /> ))} {sidePoints.slice(0, -1).map((point, pointIndex) => { @@ -293,6 +318,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. position={[point[2], point[1] + railHeight / 2, point[0]]} receiveShadow scale={[balusterRadius, railHeight, balusterRadius]} + userData={STAIR_RAILING_SLOT_USER_DATA} /> ))} {sidePath.points.slice(0, -1).map((point, pointIndex) => { @@ -398,6 +424,47 @@ const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) const STAIR_TREAD_MATERIAL_INDEX = 0 const STAIR_SIDE_MATERIAL_INDEX = 1 +const STAIR_BODY_SLOT_IDS: StairSlotId[] = ['treads', 'body'] +const STAIR_BODY_SLOT_USER_DATA = { slotIds: STAIR_BODY_SLOT_IDS } +const STAIR_BODY_SINGLE_SLOT_USER_DATA = { slotId: 'body' satisfies StairSlotId } +const STAIR_RAILING_SLOT_USER_DATA = { slotId: 'railing' satisfies StairSlotId } + +type SceneMaterials = Parameters[1] +type ViewerShading = Parameters[2] + +function hasMaterialSpec(material: unknown, materialPreset: unknown): boolean { + return material !== undefined || typeof materialPreset === 'string' +} + +function hasLegacyStairSlotMaterial(node: StairNode, slotId: StairSlotId): boolean { + const hasWhole = hasMaterialSpec(node.material, node.materialPreset) + const hasTread = hasMaterialSpec(node.treadMaterial, node.treadMaterialPreset) + const hasSide = hasMaterialSpec(node.sideMaterial, node.sideMaterialPreset) + const hasRailing = hasMaterialSpec(node.railingMaterial, node.railingMaterialPreset) + + if (slotId === 'treads') return hasTread || hasSide || hasWhole + if (slotId === 'body') return hasSide || hasTread || hasWhole + return hasRailing || hasTread || hasSide || hasWhole +} + +function resolveStairSlotMaterial( + node: StairNode, + slotId: StairSlotId, + defaultRef: string, + baseMaterial: THREE.Material, + sceneMaterials: SceneMaterials, + shading: ViewerShading, + textures: boolean, +): THREE.Material { + if (!textures) return baseMaterial + + const slotMaterial = resolveMaterialRef(node.slots?.[slotId], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + + if (hasLegacyStairSlotMaterial(node, slotId)) return baseMaterial + + return resolveSlotDefaultMaterial(defaultRef, shading) +} function RailSegment({ start, @@ -437,6 +504,7 @@ function RailSegment({ quaternion={quaternion} receiveShadow scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]} + userData={STAIR_RAILING_SLOT_USER_DATA} /> ) } @@ -591,7 +659,14 @@ function CurvedStepMesh({ ) return ( - + ) } @@ -630,6 +705,7 @@ function SpiralColumnMesh({ name="stair-side" position={[0, height / 2, 0]} receiveShadow + userData={STAIR_BODY_SINGLE_SLOT_USER_DATA} /> ) } @@ -674,6 +750,7 @@ function SpiralStepSupportMesh({ position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]} receiveShadow rotation-y={-midAngle} + userData={STAIR_BODY_SINGLE_SLOT_USER_DATA} /> ) } diff --git a/packages/nodes/src/stair/slots.ts b/packages/nodes/src/stair/slots.ts new file mode 100644 index 000000000..fdbb5eca5 --- /dev/null +++ b/packages/nodes/src/stair/slots.ts @@ -0,0 +1,20 @@ +import type { SlotDeclaration, StairNode } from '@pascal-app/core' + +export type StairSlotId = 'treads' | 'body' | 'railing' + +export const STAIR_TREADS_SLOT_DEFAULT = 'library:wood-woodplank48' +export const STAIR_BODY_SLOT_DEFAULT = 'library:preset-lightgrey' +export const STAIR_RAILING_SLOT_DEFAULT = 'library:metal-steel' + +export function stairSlots(node: StairNode): SlotDeclaration[] { + const slots: SlotDeclaration[] = [ + { slotId: 'treads', label: 'Treads', default: STAIR_TREADS_SLOT_DEFAULT }, + { slotId: 'body', label: 'Body', default: STAIR_BODY_SLOT_DEFAULT }, + ] + + if (node.railingMode && node.railingMode !== 'none') { + slots.push({ slotId: 'railing', label: 'Railing', default: STAIR_RAILING_SLOT_DEFAULT }) + } + + return slots +} diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 2f29b9ad7..473568502 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -6,6 +6,7 @@ import { wallFloorplanSiblingOverrides } from './floorplan-overrides' import { wallPaint } from './paint' import { wallParametrics } from './parametrics' import { WallNode } from './schema' +import { wallSlots } from './slots' /** * Wall — the Phase 3 stress test of the registry-driven node model. @@ -56,6 +57,11 @@ export const wallDefinition: NodeDefinition = { // preview through this entry rather than carrying a kind-name // arm. paint: wallPaint, + // Declared paintable slots (interior / exterior) with their default + // appearance — the same `{ slotId, label, default }` contract every other + // paintable kind exposes. Paint still writes the legacy inline fields via + // `wallPaint`; migrating those into `node.slots` is a later step. + slots: () => wallSlots(), }, relations: { diff --git a/packages/nodes/src/wall/move-shared.ts b/packages/nodes/src/wall/move-shared.ts index 7cb591d53..aebea59fe 100644 --- a/packages/nodes/src/wall/move-shared.ts +++ b/packages/nodes/src/wall/move-shared.ts @@ -2,7 +2,9 @@ import { type AnyNodeId, DEFAULT_WALL_HEIGHT, getMaterialPresetByRef, + parseMaterialRef, resolveMaterial, + type SceneMaterialId, useScene, type WallMoveBridgePlan, type WallNode, @@ -109,7 +111,25 @@ function wallSegmentExists( ) } +// Resolve a wall slot ref (`library:`/`scene:`) to a swatch colour, or +// undefined when the ref is absent / dangling / colourless. +function resolveWallSlotRefColor(ref: string | undefined): string | undefined { + const parsed = parseMaterialRef(ref) + if (!parsed) return undefined + if (parsed.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties.color ?? undefined + } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : undefined +} + export function getWallGhostColor(wall: WallNode) { + const slotColor = + resolveWallSlotRefColor(wall.slots?.interior) ?? resolveWallSlotRefColor(wall.slots?.exterior) + if (slotColor) { + return slotColor + } + const presetColor = getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ?? getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ?? diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index a8485944b..d4f5cfa43 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -1,14 +1,15 @@ import { + type AnyNode, type AnyNodeId, getEffectiveWallSurfaceMaterial, - type MaterialSchema, type PaintCapability, + type PaintPreviewArgs, sceneRegistry, type WallNode, type WallSurfaceSide, } from '@pascal-app/core' -import { getVisibleWallMaterials } from '@pascal-app/viewer' import type { Material, Mesh } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' /** * Resolve which side of a wall the user clicked. Walls expose two @@ -56,81 +57,59 @@ export function resolveWallRole(args: { return hitFace === 'front' ? 'interior' : 'exterior' } -export function buildWallSurfaceMaterialPatch( - node: WallNode, - targetSide: WallSurfaceSide, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextInterior = - targetSide === 'interior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'interior') - const nextExterior = - targetSide === 'exterior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'exterior') - - return { - interiorMaterial: nextInterior.material, - interiorMaterialPreset: nextInterior.materialPreset, - exteriorMaterial: nextExterior.material, - exteriorMaterialPreset: nextExterior.materialPreset, - material: undefined, - materialPreset: undefined, - } +// The wall's 3-material array maps side → group index (see +// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior. +const WALL_SIDE_MATERIAL_INDEX: Record = { + interior: 1, + exterior: 2, } /** - * Apply a preview to the wall's registered mesh by synthesising the - * post-paint node, asking the viewer's `getVisibleWallMaterials` for - * the corresponding material array, and swapping the mesh's - * material assignment until the editor calls the returned cleanup. + * Preview a wall paint by swapping just the painted face's entry in the wall + * mesh's material array. The array is the shared cached `WallMaterials.visible`, + * so we clone it before swapping and restore the original reference on cleanup + * (never mutate the cache). */ -function applyWallPreview( - node: WallNode, - role: WallSurfaceSide, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): (() => void) | null { - const mesh = sceneRegistry.nodes.get(node.id as AnyNodeId) +function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { + const { role, material, materialPreset } = args + const side = role as WallSurfaceSide + const index = WALL_SIDE_MATERIAL_INDEX[side] + if (!index) return null + + const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId) if (!(mesh && (mesh as Mesh).isMesh)) return null const wallMesh = mesh as Mesh - const previewNode: WallNode = { - ...node, - ...buildWallSurfaceMaterialPatch(node, role, material, materialPreset), - } - const nextMaterial = getVisibleWallMaterials(previewNode) - if (!nextMaterial) return null + const current = wallMesh.material + if (!Array.isArray(current)) return null + + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const previous = current as Material[] + const next = previous.slice() + next[index] = preview + wallMesh.material = next - const previousMaterial = wallMesh.material as Material | Material[] - wallMesh.material = nextMaterial return () => { - wallMesh.material = previousMaterial + wallMesh.material = previous } } /** - * Capability binding for the wall kind. The editor's - * selection-manager invokes these in place of the legacy - * `if (node.type === 'wall') { ... }` arms. + * Capability binding for the wall kind on the unified slot model. Painting + * writes `node.slots[interior|exterior]` (a `library:` ref or a minted + * `scene:` material) exactly like every other kind; `legacyEffective` reads + * the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the + * picker still shows the current value on a pre-migration scene. */ -export const wallPaint: PaintCapability = { +export const wallPaint: PaintCapability = createSlotPaintCapability({ resolveRole: ({ node, materialIndex, normal, localPosition }) => resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }), - buildPatch: ({ node, role, material, materialPreset }) => - buildWallSurfaceMaterialPatch( - node as WallNode, - role as WallSurfaceSide, - material, - materialPreset, - ), - applyPreview: ({ node, role, material, materialPreset }) => - applyWallPreview(node as WallNode, role as WallSurfaceSide, material, materialPreset), - getEffectiveMaterial: ({ node, role }) => { + applyPreview: applyWallPreview, + legacyEffective: (node: AnyNode, role: string) => { const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide) + if (spec.material === undefined && spec.materialPreset === undefined) return null return { material: spec.material, materialPreset: spec.materialPreset } }, -} +}) diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index d2d9f152b..856f0609b 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -49,7 +49,19 @@ const WallRenderer = ({ node }: { node: WallNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) - const material = getVisibleWallMaterials(node, shading, textures, colorPreset, sceneTheme) + // Subscribe to the scene-material palette so editing a `scene:` material a + // wall slot references re-renders the wall live (the wall-system geometry + // dirty loop never fires for a material-only edit). `getMaterialsForWall`'s + // content hash keeps unaffected walls on their cached materials. + const sceneMaterials = useScene((s) => s.materials) + const material = getVisibleWallMaterials( + node, + shading, + textures, + colorPreset, + sceneTheme, + sceneMaterials, + ) return ( = { // `wallId` / `roofSegmentId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + // Frame / glass slots painted through the registry. The window system tags + // each mesh with its `userData.slotId`; paint writes `node.slots`. + slots: () => windowSlots(), + paint: windowPaint, }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/paint.ts b/packages/nodes/src/window/paint.ts new file mode 100644 index 000000000..ca5120d28 --- /dev/null +++ b/packages/nodes/src/window/paint.ts @@ -0,0 +1,16 @@ +import { + createSlotPaintCapability, + previewSlotByUserData, + resolveSlotByReRaycast, +} from '../shared/slot-paint' + +/** + * Window paint on the unified slot model. The window's opening proxy (a proud, + * invisible cutout) wins the shared scene raycast over the wall in front of the + * recessed window, so `resolveSlotByReRaycast` re-raycasts the window's own + * subtree to find the part (frame / glass) under the cursor. + */ +export const windowPaint = createSlotPaintCapability({ + resolveRole: resolveSlotByReRaycast, + applyPreview: previewSlotByUserData, +}) diff --git a/packages/nodes/src/window/slots.ts b/packages/nodes/src/window/slots.ts new file mode 100644 index 000000000..4067d4d49 --- /dev/null +++ b/packages/nodes/src/window/slots.ts @@ -0,0 +1,16 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type WindowSlotId = 'frame' | 'glass' + +// Picker swatches. Rendering falls back to the live frame/glass defaults (which +// already track shading + theme), so these are just the indicator colours. +const FRAME_DEFAULT = 'library:preset-softwhite' +const GLASS_DEFAULT = 'library:preset-glass' + +/** A window exposes two paintable slots: the joinery frame and the glass. */ +export function windowSlots(): SlotDeclaration[] { + return [ + { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + ] +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index f4450ab96..6e257b525 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -13,6 +13,7 @@ import * as THREE from 'three/webgpu' import { hasDrawableGeometry } from '../../lib/drawable-geometry' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { applyIsolation, clearIsolation } from '../../lib/isolation' +import { ensureKtx2Support } from '../../lib/ktx2-loader' import type { ColorPreset, RenderShading } from '../../lib/materials' import { getSceneTheme } from '../../lib/scene-themes' import useViewer, { type RenderContext } from '../../store/use-viewer' @@ -144,6 +145,11 @@ function GPUDeviceWatcher() { const gl = useThree((s) => s.gl) useEffect(() => { + // Detect KTX2 transcode support as soon as the renderer exists, so catalog + // `.ktx2` finish textures load even in scenes with no GLB items (whose + // loader would otherwise be the only thing to call this). + ensureKtx2Support(gl) + const backend = (gl as any).backend const device = backend?.device as WebGPUDeviceLike | undefined diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 767832ed0..68b4041ba 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -284,7 +284,6 @@ const PostProcessingPasses = ({ denoise: denoiseEnabled, outline: outlineEnabled, perfDisable, - hoverHighlightMode, projectId, shading, transparentBackground, @@ -511,9 +510,12 @@ const PostProcessingPasses = ({ renderPipelineRef.current = null } }, [ + // NOTE: hoverHighlightMode intentionally excluded — the hover style is + // pushed to uniforms in a separate effect, so a hover must NOT rebuild the + // whole pipeline. The uniform refs below are stable (useMemo), so they + // never trigger a rebuild either. camera, hoverHiddenColor, - hoverHighlightMode, hoverPulseMix, hoverStrength, hoverVisibleColor, diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx new file mode 100644 index 000000000..1834c8f3e --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Environment } from '@react-three/drei' +import { Suspense } from 'react' + +/** + * Scene IBL — drei's prefiltered environment map, exported as an opt-in + * *child* rather than baked into the Viewer component, so embed / + * thumbnail surfaces that don't want the HDRI fetch simply don't mount it. + * This is what gives PBR metals their reflections and lifts the lighting on + * vertical surfaces (walls), which flat directional + hemisphere lights can't + * do alone. Intensity is dialled below the preset default so it complements + * the scene lights rather than washing them out. Only visible in `rendered` + * shading. + */ +export function SceneEnvironment() { + return ( + + + + ) +} diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 040beaf7e..000b8ff6c 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,38 +1,16 @@ import { useGLTF } from '@react-three/drei' import { useThree } from '@react-three/fiber' -import { KTX2Loader } from 'three/examples/jsm/Addons.js' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' - -const ktx2LoaderInstance = new KTX2Loader() -ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/') -const ktx2ConfiguredRenderers = new WeakSet() -const ktx2WarningLoggedRenderers = new WeakSet() +import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader' const useGLTFKTX2 = (path: string): ReturnType => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { - const renderer = gl as unknown as object - - if (!ktx2ConfiguredRenderers.has(renderer)) { - try { - ktx2LoaderInstance.detectSupport(gl) - ktx2ConfiguredRenderers.add(renderer) - } catch (error) { - // Some WebGPU flows can transiently call this before backend init. - // Avoid crashing the whole scene; scans may render without KTX2 on this pass. - if (!ktx2WarningLoggedRenderers.has(renderer)) { - console.warn('[viewer] Skipping KTX2 support detection for now.', error) - ktx2WarningLoggedRenderers.add(renderer) - } - } - } - - if (ktx2ConfiguredRenderers.has(renderer)) { + if (ensureKtx2Support(gl)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - loader.setKTX2Loader(ktx2LoaderInstance as any) + loader.setKTX2Loader(ktx2Loader as any) } - loader.setMeshoptDecoder(MeshoptDecoder) }) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index ef5a314a8..562fc73ec 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -18,6 +18,7 @@ export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' +export { SceneEnvironment } from './components/viewer/scene-environment' export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' @@ -68,6 +69,8 @@ export { MONO_PALETTE, PRESET_PALETTES, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, resolveSurfaceColor, WHITE_PALETTE, } from './lib/materials' @@ -94,7 +97,11 @@ export { ElevatorInteractionSystem } from './systems/elevator/elevator-interacti // Fence system follows the wall re-export pattern — composed into the // registry-driven fence definition's `def.system`. Removed in Phase 6 // alongside the legacy fence mount point. -export { FenceSystem, generateFenceGeometry } from './systems/fence/fence-system' +export { + FenceSystem, + generateFenceGeometry, + generateFenceSlotGeometries, +} from './systems/fence/fence-system' // Generic floor-elevation system. Lifts the rendered mesh of any kind // whose definition declares `capabilities.floorPlaced` by the slab // elevation under its footprint. Replaces the per-kind elevation block diff --git a/packages/viewer/src/lib/box-uv.ts b/packages/viewer/src/lib/box-uv.ts new file mode 100644 index 000000000..fe73d2721 --- /dev/null +++ b/packages/viewer/src/lib/box-uv.ts @@ -0,0 +1,40 @@ +import type { BoxGeometry } from 'three' + +/** + * Rewrite a default `BoxGeometry`'s UVs to world scale — 1 UV unit = 1 metre — + * so tiled finishes (with `repeat` in tiles-per-metre) render at a consistent + * real-world scale instead of stretching to fit each face. Matches the + * world-scale UV convention used by the procedural slab/wall geometry. + * + * three.js builds box faces in the fixed order [+X, -X, +Y, -Y, +Z, -Z], four + * verts each, with UVs spanning 0→1 across the face. Each face's two in-plane + * dimensions differ, so we scale U/V per face by that face's size in metres. + */ +export function applyWorldScaleBoxUVs( + geometry: BoxGeometry, + w: number, + h: number, + d: number, +): void { + const uv = geometry.getAttribute('uv') + if (!uv || uv.count < 24) return // non-default segmentation — leave as-is + + // [uScaleMetres, vScaleMetres] per face, in three's face order. + const faceScale: Array<[number, number]> = [ + [d, h], // +X + [d, h], // -X + [w, d], // +Y + [w, d], // -Y + [w, h], // +Z + [w, h], // -Z + ] + + for (let face = 0; face < 6; face += 1) { + const [us, vs] = faceScale[face]! + for (let v = 0; v < 4; v += 1) { + const i = face * 4 + v + uv.setXY(i, uv.getX(i) * us, uv.getY(i) * vs) + } + } + uv.needsUpdate = true +} diff --git a/packages/viewer/src/lib/ktx2-loader.ts b/packages/viewer/src/lib/ktx2-loader.ts new file mode 100644 index 000000000..c185ca8ff --- /dev/null +++ b/packages/viewer/src/lib/ktx2-loader.ts @@ -0,0 +1,40 @@ +import { KTX2Loader } from 'three/examples/jsm/Addons.js' + +/** + * Single shared KTX2 loader for the whole viewer — used both by the GLB loader + * (`use-gltf-ktx2`) and by catalog finish textures (`materials.ts`). KTX2 must + * be transcoded at load via the Basis WASM, and `detectSupport(renderer)` has to + * run once before any `.ktx2` is loaded so the loader picks a GPU format the + * device supports. `ensureKtx2Support` is idempotent per renderer and is called + * from the viewer root the moment the renderer is ready (even when no GLB is in + * the scene, so catalog `.ktx2` finishes still load). + */ +export const ktx2Loader = new KTX2Loader() +ktx2Loader.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/') + +const configuredRenderers = new WeakSet() +const warnedRenderers = new WeakSet() + +/** Returns true once support has been detected for this renderer (KTX2 safe to load). */ +export function ensureKtx2Support(renderer: unknown): boolean { + const key = renderer as object | null + if (!key) return false + if (configuredRenderers.has(key)) return true + try { + ;(ktx2Loader as unknown as { detectSupport: (r: unknown) => void }).detectSupport(renderer) + configuredRenderers.add(key) + return true + } catch (error) { + // Some WebGPU flows can transiently call this before backend init; don't + // crash the scene — a later call (or the next render) retries. + if (!warnedRenderers.has(key)) { + console.warn('[viewer] Skipping KTX2 support detection for now.', error) + warnedRenderers.add(key) + } + return false + } +} + +export function isKtx2Url(url: string): boolean { + return url.toLowerCase().endsWith('.ktx2') +} diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5229c603c..677b1d13f 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -4,13 +4,17 @@ import { type MaterialPresetPayload, type MaterialProperties, type MaterialSchema, + parseMaterialRef, resolveMaterial, + type SceneMaterial, + type SceneMaterialId, type SurfaceRole, } from '@pascal-app/core' import * as THREE from 'three' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { resolveCdnUrl } from './asset-url' +import { isKtx2Url, ktx2Loader } from './ktx2-loader' import { getSceneTheme } from './scene-themes' export type RenderShading = 'solid' | 'rendered' @@ -102,6 +106,14 @@ const surfaceRoleMaterialCache = new Map() const textureCache = new Map() const textureLoadPromises = new Map>() const textureLoader = new THREE.TextureLoader() + +// `.ktx2` finish maps transcode through the shared KTX2 loader (support is +// detected once at viewer init); everything else loads as a normal image. +function pickTextureLoader(url: string): THREE.TextureLoader { + // KTX2Loader's load/loadAsync are call-compatible with TextureLoader (url → + // Texture / Promise); cast for typing. + return isKtx2Url(url) ? (ktx2Loader as unknown as THREE.TextureLoader) : textureLoader +} const wrapMap = { Repeat: THREE.RepeatWrapping, ClampToEdge: THREE.ClampToEdgeWrapping, @@ -180,7 +192,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { const cached = textureCache.get(cacheKey) if (cached) return cached - const texture = textureLoader.load(textureConfig.url) + const texture = pickTextureLoader(textureConfig.url).load(textureConfig.url) texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping @@ -248,7 +260,7 @@ function getPresetTexture( const cached = textureCache.get(cacheKey) if (cached) return cached - const texture = textureLoader.load(resolvedPath) + const texture = pickTextureLoader(resolvedPath).load(resolvedPath) applyTextureProperties(texture, props, slot) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) @@ -292,7 +304,7 @@ async function loadPresetTexture( const existingPromise = textureLoadPromises.get(cacheKey) if (existingPromise) return existingPromise - const promise = textureLoader + const promise = pickTextureLoader(resolvedPath) .loadAsync(resolvedPath) .then((texture) => { applyTextureProperties(texture, props, slot) @@ -486,6 +498,44 @@ export function createMaterial( return threeMaterial } +/** + * Resolve a MaterialRef ('library:' | 'scene:') to a three.js material. + * Returns null for an unknown / dangling ref so callers fall back to the + * slot's default (authored material, then themed default). Never throws. + */ +export function resolveMaterialRef( + ref: string | undefined, + sceneMaterials: Record | undefined, + shading: RenderShading = 'rendered', +): THREE.Material | null { + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') return createMaterialFromPresetRef(ref, shading) + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return createMaterial(sceneMaterial.material, shading) +} + +/** + * Resolve a node kind's declared slot default — either a catalog `library:` + * finish or a flat `#rrggbb` colour — to a renderable material. Shared by the + * procedural kinds whose colored-mode unpainted appearance comes from a + * declarative default (slab, wall). + */ +export function resolveSlotDefaultMaterial( + slotDefault: string, + shading: RenderShading = 'rendered', + roughness = 0.9, +): THREE.Material { + if (parseMaterialRef(slotDefault)?.kind === 'library') { + return ( + createMaterialFromPresetRef(slotDefault, shading) ?? + createDefaultMaterial('#ffffff', roughness, shading) + ) + } + return createDefaultMaterial(slotDefault, roughness, shading) +} + export function createDefaultMaterial( color = '#ffffff', roughness = 0.9, diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index f9e243e81..047639910 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -5,6 +5,8 @@ import { DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, + type SceneMaterial, + type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, @@ -13,19 +15,44 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { + type ColorPreset, + createDefaultMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, + type RenderShading, + resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' }) +// Door hardware (handle / hinges / closer / panic bar) renders a catalog metal +// finish by default (chrome), separate from the door body. The flat material is +// only a fallback if the catalog ref ever fails to resolve. +const HARDWARE_DEFAULT_REF = 'library:metal-chrome' +// Door body defaults to a catalog colour (generic approach). Glass keeps the +// built-in FrontSide glass material — the catalog `preset-glass` is DoubleSide, +// which poisons the WebGPU MRT scene pass. +const PANEL_DEFAULT_REF = 'library:preset-softwhite' +const FRAME_DEFAULT_REF = 'library:preset-softwhite' +const GLASS_DEFAULT_REF = 'library:preset-glass' +const defaultHardwareMaterial = createDefaultMaterial('#3a3a3a', 0.4) let baseMaterial = getBaseMaterial() +let frameMaterial: THREE.Material = getBaseMaterial() let revealMaterial: THREE.Material = defaultRevealMaterial let glassMaterial: THREE.Material = defaultGlassMaterial +let hardwareMaterial: THREE.Material = defaultHardwareMaterial +let currentDoorSlot: string | undefined +// Per-frame viewer state, captured so the per-node mesh builder (which runs +// outside React) can resolve each door's slot materials. +let currentShading: RenderShading = 'rendered' +let currentTextures = true +let currentColorPreset: ColorPreset = 'clay' +let currentSceneMaterials: Record | undefined const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' }) const MAX_DOOR_REBUILDS_PER_FRAME = 16 @@ -50,6 +77,7 @@ export const DoorSystem = () => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const materialRevisionRef = useRef(null) // Subscribe so an override-only update (no scene write) still re-runs // the component, letting the gate below pick up the latest dirtyNodes @@ -59,8 +87,10 @@ export const DoorSystem = () => { const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial + frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial revealMaterial = textures ? defaultRevealMaterial : joineryMaterial glassMaterial = textures ? defaultGlassMaterial : joineryMaterial + hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial useEffect(() => { const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}` @@ -75,12 +105,29 @@ export const DoorSystem = () => { } }) + // Editing a scene material a door slot references must rebuild that door + // (door meshes are built by this system, not ). + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + if (node?.type !== 'door') continue + if (!nodeReferencesSceneMaterial(node)) continue + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial + frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial + hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial + currentShading = shading + currentTextures = textures + currentColorPreset = colorPreset + currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyDoorIds: AnyNodeId[] = [] @@ -134,6 +181,59 @@ export const DoorSystem = () => { return null } +function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh { + mesh.userData.slotId = currentDoorSlot + return mesh +} + +function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { + const slots = node.slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + +type DoorMaterialSlotId = 'panel' | 'frame' | 'glass' | 'hardware' + +function doorSlotDefault(slotId: DoorMaterialSlotId): THREE.Material { + if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) + if (slotId === 'glass') { + return ( + resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultGlassMaterial + ) + } + if (slotId === 'hardware') { + return ( + resolveMaterialRef(HARDWARE_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultHardwareMaterial + ) + } + if (slotId === 'frame') { + return ( + resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) + } + return ( + resolveMaterialRef(PANEL_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) +} + +// Resolve a door's slot to a material: the `node.slots` override (colored mode +// only) → the body/glass/hardware default. Textures-off ignores overrides — the +// monochrome escape hatch. +function resolveDoorSlotMaterial(node: DoorNode, slotId: DoorMaterialSlotId): THREE.Material { + const fallback = doorSlotDefault(slotId) + if (!currentTextures) return fallback + const ref = node.slots?.[slotId] + if (!ref) return fallback + return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback +} + function addBox( parent: THREE.Object3D, material: THREE.Material, @@ -144,8 +244,11 @@ function addBox( y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) + tagDoorSlot(m) parent.add(m) } @@ -160,9 +263,12 @@ function addRotatedBox( z: number, rotationY: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) m.rotation.y = rotationY + tagDoorSlot(m) parent.add(m) } @@ -177,9 +283,12 @@ function addBoxWithRotation( z: number, rotation: [number, number, number], ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) m.rotation.set(rotation[0], rotation[1], rotation[2]) + tagDoorSlot(m) parent.add(m) } @@ -196,6 +305,7 @@ function addShape( }) geometry.translate(0, 0, -depth / 2) const mesh = new THREE.Mesh(geometry, material) + tagDoorSlot(mesh) parent.add(mesh) } @@ -215,6 +325,7 @@ function addShapeAt( }) geometry.translate(x, y, z - depth / 2) const mesh = new THREE.Mesh(geometry, material) + tagDoorSlot(mesh) parent.add(mesh) } @@ -757,6 +868,7 @@ function addLeafSegmentContent({ const cpX = contentPadding[0] const cpY = contentPadding[1] if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) { + currentDoorSlot = 'panel' addLeafBox( baseMaterial, leafWidth, @@ -778,6 +890,7 @@ function addLeafSegmentContent({ } if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) { const innerH = leafHeight - 2 * cpY + currentDoorSlot = 'panel' addLeafBox( baseMaterial, cpX, @@ -857,6 +970,7 @@ function addLeafSegmentContent({ if (seg.type !== 'empty') { cx = leafCenterX - contentW / 2 + currentDoorSlot = 'panel' for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! const dividerLeft = cx @@ -894,6 +1008,7 @@ function addLeafSegmentContent({ const colX = colXCenters[c]! if (seg.type === 'glass') { + currentDoorSlot = 'glass' const glassDepth = Math.max(0.004, leafDepth * 0.15) const segmentLeft = colX - colW / 2 const segmentRight = colX + colW / 2 @@ -914,6 +1029,7 @@ function addLeafSegmentContent({ addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) } } else if (seg.type === 'panel') { + currentDoorSlot = 'panel' const segmentLeft = colX - colW / 2 const segmentRight = colX + colW / 2 const outerPanelShape = @@ -1051,6 +1167,7 @@ function addDoorLeaf( const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch' if (usesShapedLeafFrame && hasLeafContent) { + currentDoorSlot = 'panel' if (openingShape === 'rounded') { const roundedLeafShape = roundedBoundary ? createRoundedClippedLeafFrameShape( @@ -1141,6 +1258,7 @@ function addDoorLeaf( }) if (hasLeafContent && handle) { + currentDoorSlot = 'hardware' const handleY = handleHeight - doorHeight / 2 const faceZ = leafDepth / 2 const handleX = @@ -1148,20 +1266,21 @@ function addDoorLeaf( ? leafCenterX + leafWidth / 2 - 0.045 : leafCenterX - leafWidth / 2 + 0.045 - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) if (handleBothSides) { - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) + addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) + addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) } } if (hasLeafContent && doorCloser) { + currentDoorSlot = 'hardware' const closerY = leafCenterY + leafHeight / 2 - 0.04 - addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) + addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) addLeafBox( - baseMaterial, + hardwareMaterial, 0.14, 0.015, 0.015, @@ -1172,18 +1291,37 @@ function addDoorLeaf( } if (hasLeafContent && panicBar) { + currentDoorSlot = 'hardware' const barY = panicBarHeight - doorHeight / 2 - addLeafBox(baseMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03) + addLeafBox( + hardwareMaterial, + leafWidth * 0.72, + 0.04, + 0.055, + leafCenterX, + barY, + leafDepth / 2 + 0.03, + ) } if (hasLeafContent) { + currentDoorSlot = 'hardware' const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012 const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) + addBox( + mesh, + hardwareMaterial, + hingeW, + hingeH, + hingeD, + hingeMarkerX, + (leafBottom + leafTop) / 2, + 0, + ) + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) } } @@ -1222,9 +1360,10 @@ function addFoldingDoor( const panelLength = insideWidth / panelCount const foldAngle = Math.PI * 0.44 * foldAmount + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, insideWidth, Math.min(frameThickness * 0.5, 0.025), Math.max(frameDepth * 0.45, 0.035), @@ -1244,6 +1383,7 @@ function addFoldingDoor( }) } + currentDoorSlot = undefined for (let index = 0; index < panelCount; index++) { const start = vertices[index]! const end = vertices[index + 1]! @@ -1291,6 +1431,7 @@ function addFoldingDoor( keepFrameWhenEmpty: true, }) + currentDoorSlot = undefined for (const point of [start, end]) { addBox( mesh, @@ -1307,9 +1448,10 @@ function addFoldingDoor( const handlePoint = vertices[vertices.length - 1]! const handleY = handleHeight - doorHeight / 2 + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, @@ -1319,7 +1461,7 @@ function addFoldingDoor( ) addBox( mesh, - baseMaterial, + hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, @@ -1368,9 +1510,10 @@ function addPocketDoor( const handleY = handleHeight - doorHeight / 2 const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, insideWidth * 2, Math.min(frameThickness * 0.45, 0.024), Math.max(frameDepth * 0.38, 0.03), @@ -1378,6 +1521,7 @@ function addPocketDoor( topY - 0.018, 0, ) + currentDoorSlot = undefined addBox( mesh, revealMaterial, @@ -1419,8 +1563,27 @@ function addPocketDoor( segments, contentPadding, }) - addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02) - addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + 0.03, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + leafDepth / 2 + 0.02, + ) + addBox( + mesh, + hardwareMaterial, + 0.03, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + -leafDepth / 2 - 0.02, + ) } function addBarnDoor( @@ -1465,9 +1628,10 @@ function addBarnDoor( const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) const wheelY = trackY - 0.075 - addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) - addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) - addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) + currentDoorSlot = 'hardware' + addBox(mesh, hardwareMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) + addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) + addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) const addBarnLeafBox = ( material: THREE.Material, @@ -1491,6 +1655,7 @@ function addBarnDoor( keepFrameWhenEmpty: true, }) + currentDoorSlot = undefined addRotatedBox( mesh, revealMaterial, @@ -1514,11 +1679,12 @@ function addBarnDoor( 0.52, ) + currentDoorSlot = 'hardware' for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { - addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) + addBox(mesh, hardwareMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.026, 0.16, 0.026, @@ -1528,9 +1694,10 @@ function addBarnDoor( ) } + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, @@ -1540,7 +1707,7 @@ function addBarnDoor( ) addBox( mesh, - baseMaterial, + hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, @@ -1595,10 +1762,20 @@ function addSlidingDoor( const handleY = handleHeight - doorHeight / 2 const handleX = activeX + activeSign * (panelWidth / 2 - 0.06) - addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0) + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, + insideWidth, + 0.024, + Math.max(frameDepth * 0.32, 0.026), + 0, + railY, + 0, + ) + addBox( + mesh, + hardwareMaterial, insideWidth, 0.018, Math.max(frameDepth * 0.28, 0.022), @@ -1649,8 +1826,27 @@ function addSlidingDoor( contentPadding, keepFrameWhenEmpty: true, }) - addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01) - addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + 0.032, + 0.24, + 0.016, + handleX, + handleY, + frontZ + leafDepth / 2 + 0.01, + ) + addBox( + mesh, + hardwareMaterial, + 0.032, + 0.24, + 0.016, + handleX, + handleY, + frontZ - leafDepth / 2 - 0.01, + ) } function addGarageSectionalDoor( @@ -1687,9 +1883,10 @@ function addGarageSectionalDoor( const railY = leafCenterY + leafHeight / 2 - 0.04 const railZ = -travelDepth / 2 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, @@ -1699,7 +1896,7 @@ function addGarageSectionalDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, @@ -1730,6 +1927,7 @@ function addGarageSectionalDoor( const trimDepth = 0.01 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addSectionalTrim = (localY: number) => { + currentDoorSlot = undefined addBoxWithRotation( mesh, revealMaterial, @@ -1743,6 +1941,7 @@ function addGarageSectionalDoor( ) } + currentDoorSlot = 'panel' addBoxWithRotation( mesh, baseMaterial, @@ -1758,7 +1957,8 @@ function addGarageSectionalDoor( addSectionalTrim(-revealOffset) } - addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) + currentDoorSlot = 'hardware' + addBox(mesh, hardwareMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) } function addGarageRollupDoor( @@ -1791,9 +1991,10 @@ function addGarageRollupDoor( const drumY = topY + drumMaxRadius * 0.12 const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), @@ -1803,7 +2004,7 @@ function addGarageRollupDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), @@ -1813,8 +2014,10 @@ function addGarageRollupDoor( ) if (visibleHeight > 0.01) { + currentDoorSlot = 'panel' addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0) + currentDoorSlot = undefined for (let index = 0; index < visibleSlatCount; index++) { const y = topY - Math.min(visibleHeight, index * slatHeight) addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) @@ -1832,17 +2035,20 @@ function addGarageRollupDoor( ) } + currentDoorSlot = 'panel' const drum = new THREE.Mesh( new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), baseMaterial, ) drum.position.set(0, drumY, drumZ) drum.rotation.z = Math.PI / 2 + tagDoorSlot(drum) mesh.add(drum) + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, insideWidth + frameThickness, 0.026, Math.max(frameDepth * 0.52, 0.04), @@ -1881,9 +2087,10 @@ function addGarageTiltupDoor( const railY = hingeY - frameThickness * 0.35 const railZ = -railLength / 2 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, @@ -1893,7 +2100,7 @@ function addGarageTiltupDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, @@ -1902,6 +2109,7 @@ function addGarageTiltupDoor( railZ, ) + currentDoorSlot = 'panel' addBoxWithRotation( mesh, baseMaterial, @@ -1919,6 +2127,7 @@ function addGarageTiltupDoor( const trimDepth = 0.012 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => { + currentDoorSlot = undefined addBoxWithRotation( mesh, revealMaterial, @@ -1937,7 +2146,17 @@ function addGarageTiltupDoor( addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight) addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight) - addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + insideWidth, + 0.026, + Math.max(frameDepth * 0.4, 0.035), + 0, + hingeY, + 0, + ) } function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { @@ -1951,6 +2170,7 @@ function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { const node = normalizeDoorNodeForRender(rawNode) + currentDoorSlot = undefined // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -1968,6 +2188,14 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { mesh.remove(child) } + // Point the builder-facing materials at this door's slot overrides for the + // duration of its build (recomputed per node, so the next door resets cleanly + // without a restore). Reveal keeps its own material. + baseMaterial = resolveDoorSlotMaterial(node, 'panel') + frameMaterial = resolveDoorSlotMaterial(node, 'frame') + glassMaterial = resolveDoorSlotMaterial(node, 'glass') + hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware') + const { width, height, @@ -2012,6 +2240,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 // ── Frame members ── + currentDoorSlot = 'frame' if (openingShape === 'arch') { const frameBottom = -height / 2 const frameTop = height / 2 @@ -2025,7 +2254,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, postHeight, frameDepth, @@ -2035,7 +2264,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { ) addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, postHeight, frameDepth, @@ -2045,7 +2274,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { ) addShape( mesh, - baseMaterial, + frameMaterial, useShallowHeadBar ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop) : createArchBandShape( @@ -2061,7 +2290,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { } else if (openingShape === 'rounded') { addShape( mesh, - baseMaterial, + frameMaterial, createRoundedDoorFrameShape( width, height, @@ -2074,7 +2303,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Left post — full height addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, height, frameDepth, @@ -2085,7 +2314,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Right post — full height addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, height, frameDepth, @@ -2096,7 +2325,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Head (top bar) — full width addBox( mesh, - baseMaterial, + frameMaterial, width, frameThickness, frameDepth, @@ -2108,9 +2337,10 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // ── Threshold (inside the frame) ── if (threshold) { + currentDoorSlot = 'frame' addBox( mesh, - baseMaterial, + frameMaterial, insideWidth, thresholdHeight, frameDepth, @@ -2331,6 +2561,10 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' + // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it + // wins the scene raycast over the wall in front of the recessed door body — + // making it the selection AND paint hit target for the whole opening. The + // paint capability then re-raycasts the door's parts to find the slot. mesh.add(cutout) } cutout.geometry.dispose() diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index 31807dad0..b82b1fdd0 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -100,16 +100,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { if (!(position && normal)) return + // World-scale triplanar UVs: 1 UV unit = 1 metre, sampled from the part's + // local-space (already translated into fence space) coordinates with NO + // per-part origin shift. A shared origin keeps a tiled finish continuous + // across posts, rails, and infill instead of restarting the tile at each + // part's own min corner (the previous behaviour, which broke the 1 m + // contract and made adjacent parts mistile). const uvs = new Float32Array(position.count * 2) - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - - for (let index = 0; index < position.count; index += 1) { - minX = Math.min(minX, position.getX(index)) - minY = Math.min(minY, position.getY(index)) - minZ = Math.min(minZ, position.getZ(index)) - } for (let index = 0; index < position.count; index += 1) { const px = position.getX(index) @@ -123,14 +120,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { let v = 0 if (ny >= nx && ny >= nz) { - u = px - minX - v = pz - minZ + u = px + v = pz } else if (nx >= nz) { - u = pz - minZ - v = py - minY + u = pz + v = py } else { - u = px - minX - v = py - minY + u = px + v = py } uvs[index * 2] = u @@ -153,8 +150,18 @@ function getStyleDefaults(style: FenceNode['style']) { return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 } } -function createFenceParts(fence: FenceNode): FencePart[] { - const parts: FencePart[] = [] +// Paint slots map 1:1 to the fence panel's build options (Structure + the +// showInfill toggle): the end posts, the infill slats between them, the base +// kickboard, and the top rail. +export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail' + +export type FenceSlotParts = Record + +function createFenceParts(fence: FenceNode): FenceSlotParts { + const posts: FencePart[] = [] + const infill: FencePart[] = [] + const base: FencePart[] = [] + const rail: FencePart[] = [] const length = Math.max(getWallCurveLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) @@ -173,7 +180,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { const endInsetT = Math.max(0.501, 1 - edgeInset / length) if (!isFloating) { - parts.push( + base.push( ...createFenceCurveSpanParts( fence, 0, @@ -183,7 +190,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { panelDepth * 1.05, ), ) - parts.push( + base.push( ...createFenceCurveSpanParts( fence, 0, @@ -208,14 +215,18 @@ function createFenceParts(fence: FenceNode): FencePart[] { : verticalHeight const postY = fullHeightPost ? postHeight / 2 : verticalY - parts.push({ + // End posts are the structural `posts` slot; the intermediate verticals are + // the `infill` slats (only present when showInfill adds them). + // Depth is 0.001 m shy of the accent rail's `panelDepth * 0.35` so the two + // never share a coplanar face where they cross (kills the rail z-fighting). + ;(isEdgePost ? posts : infill).push({ position: [frame.point.x, postY, frame.point.y], rotationY: -frame.tangentAngle, - scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)], + scale: [postWidth, postHeight, Math.max(panelDepth * 0.35 - 0.001, 0.011)], }) } - parts.push( + rail.push( ...createFenceCurveSpanParts( fence, 0, @@ -227,7 +238,7 @@ function createFenceParts(fence: FenceNode): FencePart[] { ) if (isFloating) { - parts.push( + rail.push( ...createFenceCurveSpanParts( fence, 0, @@ -239,13 +250,15 @@ function createFenceParts(fence: FenceNode): FencePart[] { ) } - return parts + return { posts, infill, base, rail } } -export function generateFenceGeometry(fence: FenceNode) { - const parts = createFenceParts(fence) +function mergeFenceParts(parts: FencePart[]): THREE.BufferGeometry { + // An empty slot group (e.g. infill with showInfill off, or base on a floating + // fence) must not reach mergeGeometries — it throws on an empty array. The + // empty geometry has no position attribute, so the renderer skips its mesh. + if (parts.length === 0) return new THREE.BufferGeometry() const geometries = parts.map(createFencePartGeometry) - const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() geometries.forEach((geometry) => { geometry.dispose() @@ -258,6 +271,29 @@ export function generateFenceGeometry(fence: FenceNode) { return merged } +/** + * Geometry split by paint slot — posts, infill, base, rail — each a separate + * merged BufferGeometry (empty ones included) so the fence renderer can give + * each its own material + `userData.slotId`. Slots match the panel's build + * options 1:1. + */ +export function generateFenceSlotGeometries( + fence: FenceNode, +): Record { + const parts = createFenceParts(fence) + return { + posts: mergeFenceParts(parts.posts), + infill: mergeFenceParts(parts.infill), + base: mergeFenceParts(parts.base), + rail: mergeFenceParts(parts.rail), + } +} + +export function generateFenceGeometry(fence: FenceNode) { + const { posts, infill, base, rail } = createFenceParts(fence) + return mergeFenceParts([...posts, ...infill, ...base, ...rail]) +} + function updateFenceGeometry(fenceId: FenceNode['id']) { const node = useScene.getState().nodes[fenceId] if (!node || node.type !== 'fence') return diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index e66b18666..b6a400438 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -58,6 +58,10 @@ export const GeometrySystem = () => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // The shared scene-material library, threaded into each builder's ctx so + // pure geometry builders can resolve `scene:` slot refs without + // importing `useScene`. + const sceneMaterials = useScene((s) => s.materials) // Per-node cache of the last-built geometry key (for kinds that declare // `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty // but its geometry inputs are unchanged — e.g. an item reparenting onto a @@ -82,6 +86,23 @@ export const GeometrySystem = () => { } }, [shading, textures, colorPreset, sceneTheme]) + // Editing a scene material must re-colour every geometry node that + // references it through a `scene:` slot ref. Such a node's + // `geometryKey` is unchanged (only the referenced material's contents + // moved), so clear its cached key to defeat the skip in the rebuild loop, + // then mark it dirty. Scoped to nodes carrying a `scene:` ref so an + // unrelated material edit doesn't churn the whole scene. + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + const def = nodeRegistry.get(node.type) + if (!def?.geometry) continue + if (!nodeReferencesSceneMaterial(node)) continue + builtGeometryKeyRef.current.delete(node.id) + useScene.getState().markDirty(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes @@ -176,7 +197,7 @@ export const GeometrySystem = () => { const parentId = (node.parentId ?? null) as AnyNodeId | null const key: BatchKey = `${node.type}::${parentId ?? ''}` const levelData = levelDataByBatch.get(key) - const ctx = buildGeometryContext(effectiveNode, nodes, levelData) + const ctx = buildGeometryContext(effectiveNode, nodes, levelData, sceneMaterials) // The builder is typed against the kind's specific node — at the // generic system level we lose that refinement, so the cast lands @@ -229,10 +250,20 @@ export const GeometrySystem = () => { return null } +function nodeReferencesSceneMaterial(node: AnyNode): boolean { + const slots = (node as { slots?: Record }).slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + function buildGeometryContext( node: AnyNode, nodes: Record, levelData: unknown, + materials: GeometryContext['materials'], ): GeometryContext { const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined @@ -263,7 +294,7 @@ function buildGeometryContext( } } - return { resolve, children, siblings, parent, levelData } + return { resolve, children, siblings, parent, levelData, materials } } function disposeChildren(group: Group) { diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index e4040616c..8b68890d7 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -10,8 +10,20 @@ import { createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, + resolveSlotDefaultMaterial, } from '../../lib/materials' +// Declared catalog defaults for an unpainted roof, per the 4-slot layout +// (0 wall/trim · 1 deck · 2 interior soffit · 3 shingle top). The wall/trim +// band mirrors the wall kind's default (WALL_SLOT_DEFAULT = concrete-drywall) +// so a roof reads as continuous with the walls below it. +const ROOF_DEFAULT_REFS: [string, string, string, string] = [ + 'library:concrete-drywall', + 'library:preset-softwhite', + 'library:preset-softwhite', + 'library:roof-terracottatiles', +] + export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] const roofMaterialArrayCache = new Map() @@ -77,30 +89,42 @@ export function getRoofMaterialArray( roofMaterial, ] + // Textures-off (monochrome) is the guaranteed escape hatch: themed role + // colours, no catalog finishes. if (!textures) { roofMaterialArrayCache.set(cacheKey, roleArray) return roleArray } + // Textures-on default appearance: catalog finishes per slot (terracotta + // shingle, soft-white deck/soffit, wall-coloured trim). Used both when the + // roof is unpainted and to fill any individual unpainted slot below. + const defaultArray: RoofMaterialArray = [ + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[0], shading), + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[1], shading), + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[2], shading), + resolveSlotDefaultMaterial(ROOF_DEFAULT_REFS[3], shading), + ] + const topMaterial = createResolvedMaterial(top.material, top.materialPreset, shading) const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset, shading) const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset, shading) if (!(topMaterial || edgeMaterial || wallMaterial)) { - roofMaterialArrayCache.set(cacheKey, roleArray) - return roleArray + roofMaterialArrayCache.set(cacheKey, defaultArray) + return defaultArray } - // Each slot resolves to its own role only, then the themed default — never + // Each slot resolves to its own role only, then the declared default — never // another role. Cross-role fallback here used to splatter a single painted // surface (e.g. the edge) across the shingle and soffit slots. The legacy // catch-all still fills every role because `getEffectiveRoofSurfaceMaterial` // returns it for top/edge/wall alike. const materialArray: RoofMaterialArray = [ - edgeMaterial ?? roofMaterial, - wallMaterial ?? ceilingMaterial, - wallMaterial ?? ceilingMaterial, - topMaterial ?? roofMaterial, + edgeMaterial ?? defaultArray[0], + wallMaterial ?? defaultArray[1], + wallMaterial ?? defaultArray[2], + topMaterial ?? defaultArray[3], ] roofMaterialArrayCache.set(cacheKey, materialArray) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3df1ad1df..637af8bca 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -54,6 +54,59 @@ const _uvFaceNormal = new THREE.Vector3() const _uvWorldDown = new THREE.Vector3(0, -1, 0) const _uvDownSlope = new THREE.Vector3() const _uvAcrossSlope = new THREE.Vector3() +// World transform of the segment whose geometry is currently being built, so +// vertical (gable wall) faces project their UVs in WORLD space — identical to +// the wall kind's `applyWorldPlanarWallUVs` (U = ±worldX/Z, V = 1 - worldY) so +// the gable band tiles continuously into the walls below. Identity = local +// (the default outside a segment build). Set via `withSegmentUvMatrix`. +const _segmentUvMatrix = new THREE.Matrix4() +const _segmentUvNormalMatrix = new THREE.Matrix3() +const _uvWorldPoint = new THREE.Vector3() +const _uvWorldNormal = new THREE.Vector3() +const _prevSegmentUvMatrix = new THREE.Matrix4() +const _prevSegmentUvNormalMatrix = new THREE.Matrix3() + +function withSegmentUvMatrix(matrix: THREE.Matrix4, build: () => T): T { + _prevSegmentUvMatrix.copy(_segmentUvMatrix) + _prevSegmentUvNormalMatrix.copy(_segmentUvNormalMatrix) + _segmentUvMatrix.copy(matrix) + _segmentUvNormalMatrix.getNormalMatrix(matrix) + try { + return build() + } finally { + _segmentUvMatrix.copy(_prevSegmentUvMatrix) + _segmentUvNormalMatrix.copy(_prevSegmentUvNormalMatrix) + } +} + +// Scratch matrices for composing a segment's world transform (roof group ∘ +// segment) at each build, without per-call allocation. +const _segWorldMatrix = new THREE.Matrix4() +const _roofGroupMatrix = new THREE.Matrix4() +const _segLocalMatrix = new THREE.Matrix4() +const _uvTmpPos = new THREE.Vector3() +const _uvTmpQuat = new THREE.Quaternion() +const _uvUnitScale = new THREE.Vector3(1, 1, 1) + +/** Compose the world transform `T(roofPos)·Ry(roofRot) · T(segPos)·Ry(segRot)`. */ +function composeSegmentWorldMatrix( + roofPosition: readonly number[] | undefined, + roofRotation: number, + segPosition: readonly number[], + segRotation: number, +): THREE.Matrix4 { + _roofGroupMatrix.compose( + _uvTmpPos.set(roofPosition?.[0] ?? 0, roofPosition?.[1] ?? 0, roofPosition?.[2] ?? 0), + _uvTmpQuat.setFromAxisAngle(_yAxis, roofRotation), + _uvUnitScale, + ) + _segLocalMatrix.compose( + _uvTmpPos.set(segPosition[0] ?? 0, segPosition[1] ?? 0, segPosition[2] ?? 0), + _uvTmpQuat.setFromAxisAngle(_yAxis, segRotation), + _uvUnitScale, + ) + return _segWorldMatrix.multiplyMatrices(_roofGroupMatrix, _segLocalMatrix) +} const _tmpVec3A = new THREE.Vector3() const _tmpVec3B = new THREE.Vector3() const _surfaceRay = new THREE.Ray() @@ -376,7 +429,15 @@ function updateMergedRoofGeometry( let totalInner: Brush | null = null for (const child of children) { - const brushes = getRoofSegmentBrushes(child) + const brushes = withSegmentUvMatrix( + composeSegmentWorldMatrix( + roofNode.position, + roofNode.rotation ?? 0, + child.position, + child.rotation ?? 0, + ), + () => getRoofSegmentBrushes(child), + ) if (!brushes) continue subtractAccessoryCuts(brushes, child, nodes) @@ -852,7 +913,22 @@ export function generateRoofSegmentGeometry( node: RoofSegmentNode, nodes?: Record, ): THREE.BufferGeometry { - const brushes = getRoofSegmentBrushes(node) + const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined + const parentRoofPosition = + parentRoof && 'position' in parentRoof ? (parentRoof.position as number[]) : undefined + const parentRoofRotation = + parentRoof && 'rotation' in parentRoof + ? ((parentRoof as { rotation?: number }).rotation ?? 0) + : 0 + const brushes = withSegmentUvMatrix( + composeSegmentWorldMatrix( + parentRoofPosition, + parentRoofRotation, + node.position, + node.rotation ?? 0, + ), + () => getRoofSegmentBrushes(node), + ) if (!brushes) { // Fallback: simple box return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) @@ -1319,14 +1395,19 @@ function createGeometryFromFaces( } function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) { - _uvFaceNormal.copy(normal).normalize() + // Project in WORLD space (via the current segment's world transform) so + // vertical gable faces tile identically to the walls below; for a local + // build the matrix is identity and this is the original behaviour. + const p = _uvWorldPoint.copy(point).applyMatrix4(_segmentUvMatrix) + _uvFaceNormal.copy(normal).applyMatrix3(_segmentUvNormalMatrix).normalize() + _uvWorldNormal.copy(_uvFaceNormal) const absX = Math.abs(_uvFaceNormal.x) const absY = Math.abs(_uvFaceNormal.y) const absZ = Math.abs(_uvFaceNormal.z) if (absY >= absX && absY >= absZ) { - uvs.push(point.x, point.z) + uvs.push(p.x, p.z) return } @@ -1335,17 +1416,20 @@ function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) if (_uvDownSlope.lengthSq() > 1e-8) { _uvDownSlope.normalize() _uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize() - uvs.push(point.dot(_uvAcrossSlope), point.dot(_uvDownSlope)) + uvs.push(p.dot(_uvAcrossSlope), p.dot(_uvDownSlope)) return } } + // Vertical (gable wall) faces: U = ±worldX/Z (axis across the face normal), + // V = 1 - worldY — the same world-space projection the wall kind uses. + const wallV = 1 - p.y if (absX >= absZ) { - uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, -point.y) + uvs.push(_uvWorldNormal.x >= 0 ? p.z : -p.z, wallV) return } - uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, -point.y) + uvs.push(_uvWorldNormal.z >= 0 ? p.x : -p.x, wallV) } // ─── Skylight cutout ───────────────────────────────────────────────── diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 5732a6712..78addcf32 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -254,23 +254,12 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const positions: number[] = [] const uvs: number[] = [] const indices: number[] = [] - const bounds = new THREE.Box2() - - for (const [x, z] of polygon) { - bounds.expandByPoint(new THREE.Vector2(x, z)) - } - for (const hole of holePolygons) { - for (const [x, z] of hole) { - bounds.expandByPoint(new THREE.Vector2(x, z)) - } - } - - const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001) - const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001) const pushFloorVertex = (x: number, y: number, z: number) => { positions.push(x, y, z) - uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight) + // Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's + // cap mapping so a finish tiles at the same world scale on every surface. + uvs.push(x, -z) } const pushWallVertex = (x: number, y: number, z: number, u: number, v: number) => { diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 89cc6bfaf..069eea83c 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -104,7 +104,14 @@ export const WallCutout = () => { const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) - const materials = getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme) + const materials = getMaterialsForWall( + wallNode, + shading, + textures, + colorPreset, + sceneTheme, + useScene.getState().materials, + ) if (hideWall) { ;(wallMesh as Mesh).material = isDeleteHighlighted @@ -145,6 +152,7 @@ export const WallCutout = () => { useViewer.getState().textures, useViewer.getState().colorPreset, useViewer.getState().sceneTheme, + useScene.getState().materials, ) const current = wallMesh.material as Material | Material[] snapshot.set(wallMesh, current) diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 3c807dc8b..fd5efe656 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -2,9 +2,14 @@ import { getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, + parseMaterialRef, resolveMaterial, + type SceneMaterial, + type SceneMaterialId, + WALL_SLOT_DEFAULT, type WallNode, type WallSurfaceMaterialSpec, + type WallSurfaceSide, } from '@pascal-app/core' import { Color, type Material } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' @@ -12,13 +17,17 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { baseMaterial, type ColorPreset, + createDefaultMaterial, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, + resolveMaterialRef, resolveSurfaceColor, } from '../../lib/materials' +type SceneMaterials = Record | undefined + const DEFAULT_WALL_COLOR = '#f2f0ed' const WALL_HIGHLIGHT_PROFILES = { @@ -88,6 +97,86 @@ function hasExplicitMaterial(spec: WallSurfaceMaterialSpec): boolean { return Boolean(spec.materialPreset || spec.material) } +// Resolve a wall face's declared default — a catalog `library:` finish or a +// flat colour — to a renderable material. +function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Material { + if (parseMaterialRef(slotDefault)?.kind === 'library') { + return createMaterialFromPresetRef(slotDefault, shading) ?? baseMaterial(shading) + } + return createDefaultMaterial(slotDefault, 0.9, shading) +} + +// Slot-first resolution for one wall face, matching every other paintable kind: +// node.slots[side] ref → legacy inline fields → declared slot default. +// A dangling `scene:` ref (material deleted / copied across scenes) falls back +// to the declared default — it never blocks rendering (the dangling-ref rule). +function resolveWallFaceMaterial( + wallNode: WallNode, + side: WallSurfaceSide, + shading: RenderShading, + sceneMaterials: SceneMaterials, +): Material { + const ref = wallNode.slots?.[side] + if (ref) { + return ( + resolveMaterialRef(ref, sceneMaterials, shading) ?? + resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading) + ) + } + + const spec = getEffectiveWallSurfaceMaterial(wallNode, side) + if (hasExplicitMaterial(spec)) { + return getSurfaceVisibleMaterial(spec, shading) + } + + return resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading) +} + +// Cache-key fragment for one face: the slot ref plus, for a `scene:` ref, the +// referenced material's *content* — so editing a scene material assigned to a +// wall invalidates the cache (a `library:` ref is static catalog content, so +// its id alone is enough). Falls back to the legacy signature when unmigrated. +function wallFaceMaterialSignature( + wallNode: WallNode, + side: WallSurfaceSide, + sceneMaterials: SceneMaterials, +): string { + const ref = wallNode.slots?.[side] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'scene') { + return JSON.stringify({ + ref, + material: sceneMaterials?.[parsed.id as SceneMaterialId]?.material ?? null, + }) + } + return JSON.stringify({ ref }) + } + return getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(wallNode, side)) +} + +// Slot-first tint for the cutaway/invisible wall variant. +function resolveWallFaceColor( + wallNode: WallNode, + side: WallSurfaceSide, + sceneMaterials: SceneMaterials, + fallback: string, +): string { + const ref = wallNode.slots?.[side] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties?.color ?? fallback + } + if (parsed?.kind === 'scene') { + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : fallback + } + return fallback + } + return getSurfaceColor(getEffectiveWallSurfaceMaterial(wallNode, side), fallback) +} + function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { const preset = getMaterialPresetByRef(spec.materialPreset) if (preset?.mapProperties?.color) { @@ -173,15 +262,15 @@ function disposeOwnedMaterials(materials: WallMaterialArray[]) { }) } -export function getWallMaterialHash(wallNode: WallNode, shading: RenderShading): string { +export function getWallMaterialHash( + wallNode: WallNode, + shading: RenderShading, + sceneMaterials?: SceneMaterials, +): string { return JSON.stringify({ shading, - interior: getWallSurfaceMaterialSignature( - getEffectiveWallSurfaceMaterial(wallNode, 'interior'), - ), - exterior: getWallSurfaceMaterialSignature( - getEffectiveWallSurfaceMaterial(wallNode, 'exterior'), - ), + interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), + exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), }) } @@ -191,10 +280,11 @@ export function getMaterialsForWall( textures = true, colorPreset: ColorPreset = 'clay', sceneTheme?: string, + sceneMaterials?: SceneMaterials, ): WallMaterials { const cacheKey = `${wallNode.id}-${shading}-${textures}-${colorPreset}-${sceneTheme ?? 'base'}` const materialHash = textures - ? getWallMaterialHash(wallNode, shading) + ? getWallMaterialHash(wallNode, shading, sceneMaterials) : JSON.stringify({ textures, colorPreset, sceneTheme }) const existing = wallMaterialCache.get(cacheKey) @@ -212,21 +302,17 @@ export function getMaterialsForWall( ]) } - const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior') - const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior') const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) - // Untextured surfaces take the themed wall role colour even with textures on; - // only surfaces with an explicit preset/material keep their texture. + // Colored mode: each face resolves slot-first (node.slots ref → legacy inline + // fields → declared slot default, parity with the retired DEFAULT_WALL_MATERIAL). + // Textures-off collapses every face to the themed wall role (the guaranteed + // escape hatch). The edge/cap slot (index 0) stays role-based. const visible: WallMaterialArray = textures ? [ wallRoleMaterial, - hasExplicitMaterial(interiorSpec) - ? getSurfaceVisibleMaterial(interiorSpec, shading) - : wallRoleMaterial, - hasExplicitMaterial(exteriorSpec) - ? getSurfaceVisibleMaterial(exteriorSpec, shading) - : wallRoleMaterial, + resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), + resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), ] : [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial] @@ -234,11 +320,15 @@ export function getMaterialsForWall( const invisible: WallMaterialArray = [ createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'), createInvisibleWallMaterial( - textures ? getSurfaceColor(interiorSpec, wallRoleColor) : wallRoleColor, + textures + ? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor) + : wallRoleColor, textures ? shading : 'solid', ), createInvisibleWallMaterial( - textures ? getSurfaceColor(exteriorSpec, wallRoleColor) : wallRoleColor, + textures + ? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor) + : wallRoleColor, textures ? shading : 'solid', ), ] @@ -276,6 +366,8 @@ export function getVisibleWallMaterials( textures = true, colorPreset: ColorPreset = 'clay', sceneTheme?: string, + sceneMaterials?: SceneMaterials, ): WallMaterialArray { - return getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme).visible + return getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme, sceneMaterials) + .visible } diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 840075a0c..8ebe59be6 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -572,7 +572,17 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { return { ...effective, position: live.position } }) - const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) + const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) + const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) + // World transform the render mesh will apply (position + Y-rotation below). + // Reproduce it here so the UVs can be projected in WORLD space — see + // `applyWorldPlanarWallUVs`. + const wallWorldMatrix = new THREE.Matrix4().compose( + new THREE.Vector3(node.start[0], slabElevation, node.start[1]), + new THREE.Quaternion().setFromAxisAngle(WALL_UV_Y_AXIS, -wallAngle), + WALL_UV_UNIT_SCALE, + ) + const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix) mesh.geometry.dispose() mesh.geometry = newGeo @@ -589,6 +599,71 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { mesh.rotation.y = -angle } +const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0) +const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1) + +/** + * Re-project a wall's UVs in WORLD space (1 UV unit = 1 m) so the finish tiles + * continuously across adjacent walls and lines up with the roof gable above — + * instead of THREE's `ExtrudeGeometry` UVs, which restart at each wall's own + * start/end. Matches `roof-system`'s `pushRoofUv` projection exactly: vertical + * faces use `U = ±worldX/Z` (the axis across the face normal) and `V = 1 - + * worldY`; the thin top/bottom caps use `(worldX, worldZ)`. De-indexes first so + * every triangle projects by its own face normal (no shared-vertex seams at + * edges). Applied only to the render mesh; collision/floorplan geometry is + * untouched. + */ +function applyWorldPlanarWallUVs( + geometry: THREE.BufferGeometry, + worldMatrix: THREE.Matrix4, +): THREE.BufferGeometry { + const target = geometry.index ? geometry.toNonIndexed() : geometry + if (target !== geometry) geometry.dispose() + + const position = target.getAttribute('position') + if (!position || position.count === 0) return target + + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const normal = new THREE.Vector3() + const edgeAB = new THREE.Vector3() + const edgeAC = new THREE.Vector3() + const uvs = new Float32Array(position.count * 2) + + for (let i = 0; i < position.count; i += 3) { + a.fromBufferAttribute(position, i).applyMatrix4(worldMatrix) + b.fromBufferAttribute(position, i + 1).applyMatrix4(worldMatrix) + c.fromBufferAttribute(position, i + 2).applyMatrix4(worldMatrix) + edgeAB.subVectors(b, a) + edgeAC.subVectors(c, a) + normal.crossVectors(edgeAB, edgeAC).normalize() + + const absX = Math.abs(normal.x) + const absY = Math.abs(normal.y) + const absZ = Math.abs(normal.z) + + for (let k = 0; k < 3; k += 1) { + const p = k === 0 ? a : k === 1 ? b : c + let u: number + let v: number + if (absY >= absX && absY >= absZ) { + u = p.x + v = p.z + } else { + v = 1 - p.y + u = absX >= absZ ? (normal.x >= 0 ? p.z : -p.z) : normal.z >= 0 ? p.x : -p.x + } + uvs[(i + k) * 2] = u + uvs[(i + k) * 2 + 1] = v + } + } + + target.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + target.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2)) + return target +} + /** * Generates extruded wall geometry with mitering and cutouts * diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index e499b07d9..30b9a15e3 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,6 +1,8 @@ import { type AnyNodeId, getEffectiveNode, + type SceneMaterial, + type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, @@ -10,10 +12,14 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { + type ColorPreset, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, + type RenderShading, + resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' @@ -21,6 +27,13 @@ import useViewer from '../../store/use-viewer' const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) let baseMaterial = getBaseMaterial() let glassMaterial: THREE.Material = defaultGlassMaterial +let currentWindowSlot: string | undefined +// Per-frame viewer state, captured so the per-node mesh builder (which runs +// outside React) can resolve each window's slot materials. +let currentShading: RenderShading = 'rendered' +let currentTextures = true +let currentColorPreset: ColorPreset = 'clay' +let currentSceneMaterials: Record | undefined export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash' export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash' export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash' @@ -42,6 +55,7 @@ export const WindowSystem = () => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const materialRevisionRef = useRef(null) // Subscribe so override-only updates re-run this component. Mirrors // WallSystem + DoorSystem. @@ -67,6 +81,18 @@ export const WindowSystem = () => { } }) + // Editing a scene material a window slot references must rebuild that window + // (window meshes are built by this system, not , so its + // scene-material re-dirty doesn't cover them). + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + if (node?.type !== 'window') continue + if (!nodeReferencesSceneMaterial(node)) continue + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return baseMaterial = textures @@ -75,6 +101,10 @@ export const WindowSystem = () => { glassMaterial = textures ? defaultGlassMaterial : createSurfaceRoleMaterial('glazing', colorPreset) + currentShading = shading + currentTextures = textures + currentColorPreset = colorPreset + currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyWindowIds: AnyNodeId[] = [] @@ -128,6 +158,52 @@ export const WindowSystem = () => { return null } +function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh { + mesh.userData.slotId = currentWindowSlot + return mesh +} + +function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { + const slots = node.slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + +// Window frame/glass default to catalog finishes (generic approach). `preset-glass` +// is now FrontSide (it was the only glass we use), so it's safe for the WebGPU +// MRT scene pass. +const FRAME_DEFAULT_REF = 'library:preset-softwhite' +const GLASS_DEFAULT_REF = 'library:preset-glass' + +function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material { + if (slotId === 'glass') { + if (!currentTextures) return createSurfaceRoleMaterial('glazing', currentColorPreset) + return ( + resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultGlassMaterial + ) + } + if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) + return ( + resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) +} + +// Resolve a window's slot to a material: the `node.slots` override (colored mode +// only) → the role/base default. Textures-off ignores overrides — the monochrome +// escape hatch. +function resolveWindowSlotMaterial(node: WindowNode, slotId: 'frame' | 'glass'): THREE.Material { + const fallback = windowSlotDefault(slotId) + if (!currentTextures) return fallback + const ref = node.slots?.[slotId] + if (!ref) return fallback + return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback +} + function addBox( parent: THREE.Object3D, material: THREE.Material, @@ -138,8 +214,11 @@ function addBox( y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) + tagWindowSlot(m) parent.add(m) } @@ -157,6 +236,7 @@ function addShape( }) geometry.translate(0, 0, -depth / 2 + z) const mesh = new THREE.Mesh(geometry, material) + tagWindowSlot(mesh) parent.add(mesh) } @@ -493,6 +573,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = innerTop - innerBottom const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -502,6 +583,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' addShape( mesh, glassMaterial, @@ -519,6 +601,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) let x = innerLeft + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x @@ -539,6 +622,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } let y = innerTop + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y @@ -565,6 +649,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -606,10 +691,12 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) const innerSpringY = innerTop - innerArchHeight + currentWindowSlot = 'frame' addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' addShape( mesh, glassMaterial, @@ -628,6 +715,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerHalfWidth = innerW / 2 let x = innerLeft + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x @@ -648,6 +736,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } let y = innerTop + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y @@ -675,6 +764,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -714,6 +804,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -776,6 +867,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(activePanel) // Twin tracks signal the sliding operation without adding editor-only state. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -797,10 +889,12 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, ) + currentWindowSlot = 'glass' addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0) addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ) // The right sash stays fixed. The left sash is the active panel that slides across it. + currentWindowSlot = 'frame' addBox( activePanel, baseMaterial, @@ -846,6 +940,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -882,6 +977,7 @@ function addRectCasementSash( sash.rotation.y = rotationY parent.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -922,6 +1018,7 @@ function addRectCasementSash( 0, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) } @@ -934,6 +1031,7 @@ function addFrenchCasementHingeMarkers( ) { const markerW = Math.max(frameThickness * 0.38, 0.018) const markerH = innerH * 0.24 + currentWindowSlot = 'frame' for (const pivotX of [-innerW / 2, innerW / 2]) { addBox( mesh, @@ -1112,6 +1210,7 @@ function addShapedFrenchCasementSash( const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight) const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness) const sashSpringY = node.height / 2 - outerArchHeight + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1128,6 +1227,7 @@ function addShapedFrenchCasementSash( ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1153,6 +1253,7 @@ function addShapedFrenchCasementSash( fullW, leafH, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1161,6 +1262,7 @@ function addShapedFrenchCasementSash( ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1177,6 +1279,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1249,6 +1352,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1268,6 +1372,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1280,6 +1385,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1331,6 +1437,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1371,6 +1478,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1381,6 +1489,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1397,6 +1506,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1407,6 +1517,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1423,6 +1534,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1448,6 +1560,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1478,6 +1591,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1538,6 +1652,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.y = hingeSign * openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1578,9 +1693,11 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) // Small hinge markers make the pivot side legible when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1606,6 +1723,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1631,6 +1749,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1690,6 +1809,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.x = -openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1730,9 +1850,11 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sashCenterY, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08) // Compact hinge rail, visible even when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1748,6 +1870,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1767,6 +1890,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1779,6 +1903,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1816,6 +1941,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1826,6 +1952,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1842,6 +1969,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1852,6 +1980,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1868,6 +1997,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1883,6 +2013,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1908,6 +2039,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1965,6 +2097,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.x = -openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1996,9 +2129,11 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH / 2, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08) // Compact bottom hinge rail, visible even when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2014,6 +2149,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2033,6 +2169,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -2045,6 +2182,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -2081,6 +2219,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -2091,6 +2230,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -2107,6 +2247,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -2117,6 +2258,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -2133,6 +2275,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2148,6 +2291,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2171,6 +2315,7 @@ function addHungSash( glassW: number, glassH: number, ) { + currentWindowSlot = 'frame' addBox( parent, baseMaterial, @@ -2211,6 +2356,7 @@ function addHungSash( 0, 0, ) + currentWindowSlot = 'glass' addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0) } @@ -2221,6 +2367,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2285,6 +2432,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(activeSash) // Side tracks show the lower sash is the moving element. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2331,6 +2479,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { ) // Meeting rails: top sash fixed, bottom sash moves upward over it. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2356,6 +2505,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2376,6 +2526,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2444,6 +2595,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(bottomSash) // Side tracks show both sashes move vertically. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2487,6 +2639,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { ) // Opposing meeting rails: top sash descends while bottom sash rises. + currentWindowSlot = 'frame' addBox( topSash, baseMaterial, @@ -2512,6 +2665,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2530,6 +2684,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2590,6 +2745,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const addBayPanel = (parent: THREE.Object3D, panelW: number) => { const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) + currentWindowSlot = 'frame' addBox( parent, baseMaterial, @@ -2630,10 +2786,12 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, 0, ) + currentWindowSlot = 'glass' addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08) } const addBayCap = (centerY: number) => { + currentWindowSlot = 'frame' const halfThickness = frameThickness / 2 const vertices: number[] = [] const indices: number[] = [] @@ -2688,7 +2846,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() - mesh.add(new THREE.Mesh(geometry, baseMaterial)) + mesh.add(tagWindowSlot(new THREE.Mesh(geometry, baseMaterial))) } const center = new THREE.Group() @@ -2715,6 +2873,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2733,6 +2892,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2859,15 +3019,19 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } const addCurvedMesh = (material: THREE.Material, geometry: THREE.BufferGeometry) => { - mesh.add(new THREE.Mesh(geometry, material)) + mesh.add(tagWindowSlot(new THREE.Mesh(geometry, material))) } + currentWindowSlot = 'frame' addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2)) addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom)) + currentWindowSlot = 'glass' addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04)) + currentWindowSlot = 'frame' addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness)) addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness)) + currentWindowSlot = 'frame' for (let index = 0; index <= mullionCount; index += 1) { const x = -halfSpan + (innerW * index) / mullionCount addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x)) @@ -2877,6 +3041,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2901,6 +3066,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2955,6 +3121,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { slats.name = LOUVERED_WINDOW_SLATS_NAME mesh.add(slats) + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2976,6 +3143,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, ) + currentWindowSlot = 'glass' for (let index = 0; index < slatCount; index += 1) { const y = innerH / 2 - slatGap * (index + 0.5) const slat = new THREE.Group() @@ -2998,6 +3166,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3025,6 +3194,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = innerTop - innerBottom if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -3037,6 +3207,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -3087,6 +3258,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { })() const addVerticalRail = (x: number) => { + currentWindowSlot = 'frame' const railX1 = x const railX2 = x + (x < 0 ? railThickness : -railThickness) const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2) @@ -3120,6 +3292,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { addVerticalRail(innerLeft) addVerticalRail(innerRight) + currentWindowSlot = 'glass' for (let index = 0; index < slatCount; index += 1) { const y = innerTop - slatGap * (index + 0.5) const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop)) @@ -3140,6 +3313,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3154,6 +3328,8 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { + currentWindowSlot = undefined + // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) @@ -3170,6 +3346,12 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { mesh.remove(child) } + // Point the builder-facing frame/glass materials at this window's slot + // overrides for the duration of its build (recomputed per node, so the next + // window resets cleanly without a restore). + baseMaterial = resolveWindowSlotMaterial(node, 'frame') + glassMaterial = resolveWindowSlotMaterial(node, 'glass') + const { width, height, @@ -3263,6 +3445,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // ── Frame members ── // Top / bottom — full width + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3337,6 +3520,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Column dividers — full inner height cx = -innerW / 2 + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! addBox( @@ -3354,6 +3538,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Row dividers — per column width, so they don't overlap column dividers (top to bottom) cy = innerH / 2 + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { cy -= rowHeights[r]! const divY = cy - rowDividerThickness / 2 @@ -3374,6 +3559,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Glass panes const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' for (let c = 0; c < numCols; c++) { for (let r = 0; r < numRows; r++) { addBox( @@ -3394,6 +3580,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { const sillW = width + sillDepth * 0.4 // slightly wider than frame // Protrudes from the front face of the frame (+Z) const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3415,6 +3602,10 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' + // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it + // wins the scene raycast over the wall in front of the recessed window — + // making it the selection AND paint hit target for the whole opening. The + // paint capability then re-raycasts the window's parts to find the slot. mesh.add(cutout) } cutout.geometry.dispose() diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index c96c418a9..47de0ccd4 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -80,3 +80,17 @@ The editor UI chrome is always dark (a fixed `document.body.classList.add('dark' ## Adding a theme Append a `SceneTheme` to `SCENE_THEMES` with all required fields. `clayTints` is a `Partial` — any role you omit falls back to the active `colorPreset`. The theme pickers (toolbar + community overlay) render a 2×2 swatch from `clayTints` over `background`, so populate at least `wall`/`roof`/`floor`/`glazing` for a good swatch. + +## Texture world scale (UVs in metres) + +Every procedural surface generates UVs in metres: 1 UV unit = 1 m. + +This contract is shared by wall `systems/wall/wall-system.tsx` (`ExtrudeGeometry`), slab `systems/slab/slab-system.tsx` (`generatePositiveSlabGeometry`, and `generatePoolGeometry`), ceiling `systems/ceiling/ceiling-system.tsx`, roof `systems/roof/roof-system.tsx`, and chimney/dormer `nodes/src/chimney/geometry.ts`. + +GLB item slots follow the same ~1 UV unit/m authoring convention, enforced by the slot validator's UV-presence check and the phase-6 Blender recipe. This is an authoring requirement, not a render-time correction. + +A catalog material's `repeat` (`mapProperties.repeatX/repeatY` in `packages/core/src/material-library.ts`) is therefore a per-material world-scale setting: tiles per metre. + +`repeat: 1` means 1 tile/m, `0.4` means one tile every 2.5 m, and `1.5` means 1.5 tiles/m. + +Repeat is a property of the material, identical for every surface that uses it, never per-item or per-surface. Custom repeat values are intentional material scale, not per-surface hacks.