diff --git a/core/version.rb b/core/version.rb index 9208458..c136ed6 100644 --- a/core/version.rb +++ b/core/version.rb @@ -6,5 +6,5 @@ module KorpCraft VERSION = '1.0'.freeze # Порядковый номер сборки. Инкрементирует build/bump_build.ps1 первым шагом # rebuild_release.bat: один прогон bat = один номер на все 3 RBZ. - BUILD = 67 + BUILD = 70 end diff --git a/features/back_panel/commands/commit_command.rb b/features/back_panel/commands/commit_command.rb index 374f67a..e717c4d 100644 --- a/features/back_panel/commands/commit_command.rb +++ b/features/back_panel/commands/commit_command.rb @@ -8,6 +8,13 @@ module KorpCraft tool = KorpCraft::Tools::BackPanelTool.active_instance contour = tool&.contour_spec raise 'invalid_contour' unless contour&.valid? + offset_result = OffsetBuilder.build( + contour, + params['offset_mm'].to_f, + params['selected_offset_edges'] || [] + ) + raise "invalid_offset:#{offset_result.reason}" unless offset_result.valid? + model = defined?(Sketchup) ? Sketchup.active_model : nil raise 'no_model' unless model raise 'operation_start_failed' unless @operation_manager.start(model: model, title: 'KorpCraft Back Panel', transparent: false) diff --git a/features/back_panel/constants.rb b/features/back_panel/constants.rb index 4c6ca01..601fe2d 100644 --- a/features/back_panel/constants.rb +++ b/features/back_panel/constants.rb @@ -21,7 +21,9 @@ module KorpCraft 'groove_nominal_width_mm' => 4.0, 'groove_width_mm' => 4.0, 'groove_depth_mm' => 6.0, - 'assembly_clearance_mm' => 0.5 + 'assembly_clearance_mm' => 0.5, + 'offset_mm' => 0.0, + 'selected_offset_edges' => [] }.freeze end end diff --git a/features/back_panel/contour_builder.rb b/features/back_panel/contour_builder.rb index 7bbc672..6a46c53 100644 --- a/features/back_panel/contour_builder.rb +++ b/features/back_panel/contour_builder.rb @@ -9,6 +9,7 @@ module KorpCraft MAX_CANDIDATES = 64 class CandidateLimitError < StandardError; end + CanonicalLoop = Struct.new(:points, :segment_groups, keyword_init: true) def from_points(points, sources: [], normal_hint: nil, groove_enabled: false) list = Array(points).compact @@ -18,6 +19,8 @@ module KorpCraft normal = normalized(normal_hint) || polygon_normal(list) return invalid('degenerate_plane') unless normal return invalid('not_coplanar') unless coplanar?(list, normal) + return invalid('degenerate_edge') if degenerate_edge?(list) + return invalid('backtracking_edge') if backtracking_edge?(list) return invalid('self_intersection') if self_intersecting?(list, normal) segments = list.each_index.map do |index| @@ -179,6 +182,69 @@ module KorpCraft sum.abs * 0.5 end + def sanitized_outer_loop_points(points, normal_hint = nil) + list = Array(points).compact + return [] if list.length < 3 + deduped = [] + list.each do |pt| + deduped << pt if deduped.empty? || !same_point?(deduped.last, pt) + end + deduped.pop if deduped.length > 3 && same_point?(deduped.first, deduped.last) + return deduped if deduped.length < 3 + normal = normalized(normal_hint) || polygon_normal(deduped) + return deduped unless normal + result = [] + len = deduped.length + len.times do |i| + prev = deduped[(i - 1) % len] + curr = deduped[i] + nxt = deduped[(i + 1) % len] + v1 = prev.vector_to(curr) + v2 = curr.vector_to(nxt) + n1 = normalized(v1) + n2 = normalized(v2) + if n1 && n2 && (n1.dot(n2) - 1.0).abs <= 1.0e-6 + next + end + cross_len = v1.cross(v2).length.to_f + len_prod = v1.length.to_f * v2.length.to_f + if len_prod > 1.0e-12 && (cross_len / len_prod) <= 1.0e-6 && v1.dot(v2).positive? + next + end + result << curr + end + result.length >= 3 ? result : deduped + rescue StandardError + Array(points).compact + end + + def canonical_outer_loop(contour) + return CanonicalLoop.new(points: [], segment_groups: []) unless contour&.valid? + + points = Array(contour.points) + kept = canonical_point_indices(points, contour.normal) + return CanonicalLoop.new(points: points, segment_groups: points.each_index.map { |index| [index] }) if kept.length < 3 + + groups = kept.each_index.map do |position| + first = kept[position] + finish = kept[(position + 1) % kept.length] + indexes = [] + index = first + while index != finish + indexes << index + index = (index + 1) % points.length + end + indexes + end + CanonicalLoop.new(points: kept.map { |index| points[index] }, segment_groups: groups) + rescue StandardError + CanonicalLoop.new(points: [], segment_groups: []) + end + + def outer_loop_points(contour) + canonical_outer_loop(contour).points + end + def invalid(reason) ContourSpec.new(segments: [], points: [], normal: nil, reason: reason) end @@ -204,6 +270,51 @@ module KorpCraft JOIN_TOLERANCE_MM.respond_to?(:mm) ? JOIN_TOLERANCE_MM.mm.to_f : JOIN_TOLERANCE_MM / 25.4 end + def canonical_point_indices(points, normal_hint = nil) + list = Array(points) + return list.each_index.to_a if list.length < 3 + + normal = normalized(normal_hint) || polygon_normal(list) + return list.each_index.to_a unless normal + + list.each_index.reject do |index| + prev = list[(index - 1) % list.length] + current = list[index] + following = list[(index + 1) % list.length] + same_direction_collinear?(prev.vector_to(current), current.vector_to(following)) + end + rescue StandardError + Array(points).each_index.to_a + end + + def degenerate_edge?(points) + Array(points).each_index.any? do |index| + same_point?(points[index], points[(index + 1) % points.length]) + end + end + + def backtracking_edge?(points) + Array(points).each_index.any? do |index| + previous = points[(index - 1) % points.length] + current = points[index] + following = points[(index + 1) % points.length] + first = previous.vector_to(current) + second = current.vector_to(following) + collinear_vectors?(first, second) && first.dot(second).negative? + end + end + + def same_direction_collinear?(first, second) + collinear_vectors?(first, second) && first.dot(second).positive? + end + + def collinear_vectors?(first, second) + length_product = first.length.to_f * second.length.to_f + return false if length_product <= 1.0e-12 + + first.cross(second).length.to_f / length_product <= 1.0e-6 + end + def same_point?(first, second) return false unless first && second distance(first, second) <= tolerance diff --git a/features/back_panel/dialog_manager.rb b/features/back_panel/dialog_manager.rb index e554f41..b63b29c 100644 --- a/features/back_panel/dialog_manager.rb +++ b/features/back_panel/dialog_manager.rb @@ -39,9 +39,10 @@ module KorpCraft def build_state(params: nil) tool = KorpCraft::Tools::BackPanelTool.active_instance if defined?(KorpCraft::Tools::BackPanelTool) spec = tool&.contour_spec - normalized = ParamsNormalizer.normalize(params || StateRepository.instance.load_params) + normalized = ParamsNormalizer.normalize(params || tool&.params || StateRepository.instance.load_params) reason = tool&.contour_reason.to_s reason = spec&.reason.to_s if reason.empty? + offset_valid = tool ? tool.offset_valid? : true { params: normalized, material_types: material_types(normalized), @@ -54,6 +55,9 @@ module KorpCraft groove_segments: spec ? spec.grooved_segments.length : 0, excluded_groove_segments: spec ? spec.excluded_groove_segments.length : 0, groove_exclusion_mode: tool&.groove_exclusion_mode? == true, + offset_selection_mode: tool&.offset_selection_mode == true, + offset_valid: offset_valid, + offset_reason: offset_valid ? '' : tool&.offset_reason.to_s, trim_candidates: trim_candidate_count(tool, spec, normalized) } rescue StandardError => e @@ -61,7 +65,8 @@ module KorpCraft { params: DEFAULT_PARAMS.dup, material_types: [DEFAULT_PARAMS['material_type']], picks_done: 0, contour_ready: false, groove_segments: 0, excluded_groove_segments: 0, selection_phase: 'collecting', candidate_count: 0, can_finish: false, - groove_exclusion_mode: false, trim_candidates: 0 } + groove_exclusion_mode: false, offset_selection_mode: false, + offset_valid: true, offset_reason: '', trim_candidates: 0 } end def push_state(state) @@ -123,6 +128,35 @@ module KorpCraft push_state(build_state) end dialog.add_action_callback('kc_back_panel_close') { |_ctx, raw| execute('close', raw) } + + dialog.add_action_callback('kc_back_panel_enable_offset_mode') do |_ctx| + tool = KorpCraft::Tools::BackPanelTool.active_instance + tool&.enable_offset_mode + push_state(build_state(params: tool&.params)) + end + dialog.add_action_callback('kc_back_panel_finish_offset_selection') do |_ctx| + tool = KorpCraft::Tools::BackPanelTool.active_instance + tool&.finish_offset_selection + push_state(build_state(params: tool&.params)) + end + dialog.add_action_callback('kc_back_panel_select_offset_edge') do |_ctx, raw| + payload = parse(raw) + edge_index = payload['edge_index']&.to_i + tool = KorpCraft::Tools::BackPanelTool.active_instance + tool&.toggle_selected_offset_edge(edge_index) + push_state(build_state(params: tool&.params)) + end + dialog.add_action_callback('kc_back_panel_update_offset_value') do |_ctx, raw| + payload = parse(raw) + offset_value = payload['offset_mm']&.to_f + tool = KorpCraft::Tools::BackPanelTool.active_instance + if tool + merged = tool.params.merge('offset_mm' => offset_value) + params = StateRepository.instance.save_params(merged) + tool.update_params(params) + end + push_state(build_state) + end end def execute(name, raw) @@ -152,8 +186,10 @@ module KorpCraft def trim_candidate_count(tool, spec, params) return 0 unless tool && spec&.valid? + return 0 unless tool.offset_valid? + model = defined?(Sketchup) ? Sketchup.active_model : nil - BooleanService.preview_trim_candidates(model, spec, params).length + BooleanService.preview_trim_candidates(model, tool.offset_preview_contour, params).length rescue StandardError 0 end diff --git a/features/back_panel/geometry_builder.rb b/features/back_panel/geometry_builder.rb index bfc6079..ae3b9bf 100644 --- a/features/back_panel/geometry_builder.rb +++ b/features/back_panel/geometry_builder.rb @@ -9,11 +9,14 @@ module KorpCraft def build(model, contour, raw_params) raise 'invalid_contour' unless contour&.valid? params = ParamsNormalizer.normalize(raw_params) - normal = effective_normal(contour, params) - world_points = prepared_world_points(contour, params) + working_contour = apply_offset_to_contour(contour, params) + normal = effective_normal(working_contour, params) + world_points = prepared_world_points(working_contour, params) active_transform = model.respond_to?(:edit_transform) ? model.edit_transform.inverse : Geom::Transformation.new active_points = world_points.map { |point| point.transform(active_transform) } active_normal = normal.transform(active_transform) + active_points = KorpCraft::Features::BackPanel::ContourBuilder.sanitized_outer_loop_points(active_points, active_normal) + raise 'insufficient_points' if active_points.length < 3 length_mm, width_mm = planar_dimensions_mm(active_points, active_normal) panel = KorpCraft::Features::Panels.build_panel_group( @@ -37,7 +40,7 @@ module KorpCraft UndoChain.continue!(model) end - write_metadata(panel, contour, params, world_points, normal) + write_metadata(panel, working_contour, params, world_points, normal) apply_material(model, panel, params) { panel: panel, points_world: world_points, normal_world: normal, params: params } rescue StandardError => e @@ -52,6 +55,17 @@ module KorpCraft normal end + def apply_offset_to_contour(contour, params) + offset_mm = params['offset_mm'].to_f + return contour if offset_mm.abs <= 0.01 + + selected_edges = params['selected_offset_edges'] || [] + result = OffsetBuilder.build(contour, offset_mm, selected_edges) + raise "invalid_offset:#{result.reason}" unless result.valid? + + result.contour + end + def prepared_world_points(contour, params) points = expanded_points(contour, params) normal = effective_normal(contour, params) diff --git a/features/back_panel/init.rb b/features/back_panel/init.rb index 0eab560..a7d876a 100644 --- a/features/back_panel/init.rb +++ b/features/back_panel/init.rb @@ -8,6 +8,7 @@ require_relative 'undo_chain' require_relative 'contour_spec' require_relative 'contour_builder' require_relative 'geometry_builder' +require_relative 'offset_builder' require_relative '../drawing/contour_source' require_relative 'boolean_service' require_relative 'commands/base_command' diff --git a/features/back_panel/params_normalizer.rb b/features/back_panel/params_normalizer.rb index dda22f9..8a58e05 100644 --- a/features/back_panel/params_normalizer.rb +++ b/features/back_panel/params_normalizer.rb @@ -25,6 +25,11 @@ module KorpCraft params['groove_nominal_width_mm'] = nominal_width params['groove_width_mm'] = nominal_width + params['assembly_clearance_mm'].to_f params['groove_width_mm'] = params['thickness_mm'] if params['groove_width_mm'] < params['thickness_mm'] + + params['offset_mm'] = number(source['offset_mm'], params['offset_mm'] || 0.0) + # Инвариант: пустой массив => отступ СО ВСЕХ СТОРОН. Непустой => только выбранные рёбра (канонические индексы). + params['selected_offset_edges'] = parse_selected_edges(source['selected_offset_edges']) + params rescue StandardError DEFAULT_PARAMS.dup @@ -56,6 +61,13 @@ module KorpCraft rescue StandardError fallback end + + def parse_selected_edges(value) + return [] unless value.is_a?(Array) + value.map { |edge| Integer(edge) rescue nil }.compact.select { |edge| edge >= 0 }.uniq + rescue StandardError + [] + end end end end diff --git a/features/back_panel/state_repository.rb b/features/back_panel/state_repository.rb index 5b0878b..418aba1 100644 --- a/features/back_panel/state_repository.rb +++ b/features/back_panel/state_repository.rb @@ -12,7 +12,12 @@ module KorpCraft @mutex.synchronize do raw = Core::UiState.safe_read_default(STATE_KEY, '') text = Core::UiState.respond_to?(:decode_pref_payload) ? Core::UiState.decode_pref_payload(raw).to_s : raw.to_s - ParamsNormalizer.normalize(text.strip.empty? ? {} : JSON.parse(text)) + params = ParamsNormalizer.normalize(text.strip.empty? ? {} : JSON.parse(text)) + if params['selected_offset_edges'].any? + params['selected_offset_edges'] = [] + Core::UiState.safe_write_default(STATE_KEY, params.to_json) + end + params end rescue StandardError DEFAULT_PARAMS.dup @@ -21,7 +26,8 @@ module KorpCraft def save_params(value) @mutex.synchronize do params = ParamsNormalizer.normalize(value) - Core::UiState.safe_write_default(STATE_KEY, params.to_json) + persisted = params.merge('selected_offset_edges' => []) + Core::UiState.safe_write_default(STATE_KEY, persisted.to_json) params end rescue StandardError diff --git a/html/back_panel_dialog.html b/html/back_panel_dialog.html index d751845..cbc9fa7 100644 --- a/html/back_panel_dialog.html +++ b/html/back_panel_dialog.html @@ -82,6 +82,18 @@ +
+
Отступы от краёв
+
+ + мм +
+
+ + +
+
+
Выберите контур.
@@ -99,7 +111,7 @@ diff --git a/tools/back_panel_tool.rb b/tools/back_panel_tool.rb index ace7d75..6da1cf6 100644 --- a/tools/back_panel_tool.rb +++ b/tools/back_panel_tool.rb @@ -21,7 +21,7 @@ module KorpCraft end end - attr_reader :contour_spec + attr_reader :contour_spec, :params def initialize(params: {}, dialog_manager: nil) @params = KorpCraft::Features::BackPanel::ParamsNormalizer.normalize(params) @@ -37,6 +37,7 @@ module KorpCraft @hover = nil @hover_segment_index = nil @groove_exclusion_mode = false + @offset_selection_mode = false @input_point = defined?(Sketchup::InputPoint) ? Sketchup::InputPoint.new : nil end @@ -49,7 +50,20 @@ module KorpCraft end def deactivate(view) + @segments = [] + @manual_points = [] + @contour_spec = nil + @candidate_specs = [] + @selection_phase = :collecting + @active_candidate_index = nil + @hover_candidate_index = nil + @contour_reason = '' + @hover = nil + @hover_segment_index = nil + @groove_exclusion_mode = false + @offset_selection_mode = false self.class.active_instance = nil if self.class.active_instance.equal?(self) + sync_state view.invalidate if view&.respond_to?(:invalidate) rescue StandardError nil @@ -80,7 +94,9 @@ module KorpCraft @params['flip'] = !@params['flip'] end when KEY_ENTER - handled = if @selection_phase == :collecting + handled = if @offset_selection_mode && @selection_phase == :ready + finish_offset_selection + elsif @selection_phase == :collecting finish_selection elsif @selection_phase == :choosing confirm_candidate(@hover_candidate_index || @active_candidate_index) @@ -107,7 +123,11 @@ module KorpCraft def onMouseMove(_flags, x, y, view) if @selection_phase == :ready && @contour_spec&.valid? - @hover_segment_index = @groove_exclusion_mode ? nearest_contour_segment(view, x, y) : nil + if @offset_selection_mode + @hover_segment_index = nearest_contour_segment(view, x, y) + else + @hover_segment_index = @groove_exclusion_mode ? nearest_contour_segment(view, x, y) : nil + end view.invalidate if view&.respond_to?(:invalidate) return end @@ -124,12 +144,19 @@ module KorpCraft def onLButtonDown(_flags, x, y, view) if @selection_phase == :ready && @contour_spec&.valid? - return false unless @groove_exclusion_mode - toggled = toggle_hovered_groove - UI.beep unless toggled - sync_state - view.invalidate if view&.respond_to?(:invalidate) - return toggled + if @offset_selection_mode + toggled = toggle_selected_offset_edge(@hover_segment_index) + UI.beep unless toggled + sync_state + view.invalidate if view&.respond_to?(:invalidate) + return toggled + else + toggled = @groove_exclusion_mode ? toggle_hovered_groove : false + UI.beep unless toggled + sync_state + view.invalidate if view&.respond_to?(:invalidate) + return toggled + end end if @selection_phase == :choosing selected = confirm_candidate(@hover_candidate_index) @@ -168,6 +195,7 @@ module KorpCraft def update_params(value) @params = KorpCraft::Features::BackPanel::ParamsNormalizer.normalize(value) rebuild_contour + update_status Sketchup.active_model&.active_view&.invalidate if defined?(Sketchup) sync_state rescue StandardError => e @@ -186,7 +214,102 @@ module KorpCraft @hover = nil @hover_segment_index = nil @groove_exclusion_mode = false + @offset_selection_mode = false + sync_state + end + + def offset_selection_mode + @offset_selection_mode == true + end + + def enable_offset_mode + return false unless @selection_phase == :ready && @contour_spec&.valid? + @offset_selection_mode = true + @groove_exclusion_mode = false + @params['selected_offset_edges'] = [] if (@params['selected_offset_edges'] || []).empty? + @hover_segment_index = nil + update_status + sync_state + Sketchup.active_model&.active_view&.invalidate if defined?(Sketchup) + true + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_mode', e) + false + end + + def finish_offset_selection + return false unless @offset_selection_mode + @offset_selection_mode = false + @hover_segment_index = nil + update_status + sync_state + Sketchup.active_model&.active_view&.invalidate if defined?(Sketchup) + true + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_finish', e) + false + end + + def offset_result + KorpCraft::Features::BackPanel::OffsetBuilder.build( + @contour_spec, + @params['offset_mm'].to_f, + @params['selected_offset_edges'] || [] + ) + end + + def offset_preview_contour + result = offset_result + result.valid? ? result.contour : @contour_spec + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_preview', e) + @contour_spec + end + + def offset_valid? + return true unless @contour_spec&.valid? + + offset_result.valid? + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_valid', e) + false + end + + def offset_reason + return '' unless @contour_spec&.valid? + + offset_result.reason.to_s + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_reason', e) + 'offset_failed' + end + + def toggle_selected_offset_edge(index) + return false unless @offset_selection_mode && index.is_a?(Integer) + selected = @params['selected_offset_edges'] ||= [] + if selected.include?(index) + selected.delete(index) + else + selected << index + end + @params['selected_offset_edges'] = selected + update_status + sync_state + true + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_toggle', e) + false + end + + def update_offset_value(offset_mm) + return false unless offset_mm.is_a?(Numeric) + @params['offset_mm'] = offset_mm.to_f sync_state + Sketchup.active_model&.active_view&.invalidate if defined?(Sketchup) + true + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('back_panel_tool_offset_value', e) + false end def groove_exclusion_mode? @@ -197,7 +320,8 @@ module KorpCraft return false unless @contour_spec&.valid? && @params['groove_enabled'] @groove_exclusion_mode = !@groove_exclusion_mode - @hover_segment_index = nil unless @groove_exclusion_mode + @offset_selection_mode = false if @groove_exclusion_mode + @hover_segment_index = nil unless @groove_exclusion_mode || @offset_selection_mode Sketchup.active_model&.active_view&.invalidate if defined?(Sketchup) sync_state true @@ -404,7 +528,9 @@ module KorpCraft @hover_candidate_index = nil @hover_segment_index = nil @groove_exclusion_mode = false + @offset_selection_mode = false @contour_reason = '' + @params['selected_offset_edges'] = [] end def confirm_candidate(index) @@ -420,6 +546,9 @@ module KorpCraft @hover_segment_index = nil @groove_exclusion_mode = false @contour_reason = '' + @params['selected_offset_edges'] = [] + sync_state + Sketchup.active_model&.active_view&.invalidate if defined?(Sketchup) true end @@ -461,7 +590,10 @@ module KorpCraft def draw_candidate_choices(view) highlighted = @hover_candidate_index || @active_candidate_index @candidate_specs.each_with_index do |spec, index| - points = KorpCraft::Features::BackPanel::GeometryBuilder.prepared_world_points(spec, @params) + raw_pts = KorpCraft::Features::BackPanel::GeometryBuilder.prepared_world_points(spec, @params) + cn = KorpCraft::Features::BackPanel::GeometryBuilder.effective_normal(spec, @params) + points = KorpCraft::Features::BackPanel::ContourBuilder.sanitized_outer_loop_points(raw_pts, cn) + points = raw_pts if points.length < 3 next if points.length < 3 if index == highlighted @@ -533,8 +665,13 @@ module KorpCraft end def draw_preview(view) - points = KorpCraft::Features::BackPanel::GeometryBuilder.prepared_world_points(@contour_spec, @params) - normal = KorpCraft::Features::BackPanel::GeometryBuilder.effective_normal(@contour_spec, @params) + preview_contour = offset_preview_contour + raw_points = KorpCraft::Features::BackPanel::GeometryBuilder.prepared_world_points(preview_contour, @params) + normal = KorpCraft::Features::BackPanel::GeometryBuilder.effective_normal(preview_contour, @params) + points = KorpCraft::Features::BackPanel::ContourBuilder.sanitized_outer_loop_points(raw_points, normal) + points = raw_points if points.length < 3 + return if points.length < 3 + thickness = KorpCraft::Features::BackPanel::GeometryBuilder.model_length(@params['thickness_mm']) rear = points.map { |point| point.offset(normal, thickness) } view.drawing_color = Sketchup::Color.new(40, 200, 100, 80) @@ -545,7 +682,8 @@ module KorpCraft view.draw(GL_LINE_LOOP, rear) points.each_index { |index| view.draw(GL_LINES, [points[index], rear[index]]) } draw_groove_preview(view, normal) - draw_trim_candidates(view) + draw_trim_candidates(view, preview_contour) + draw_offset_edges(view) if @offset_selection_mode end def draw_groove_preview(view, normal) @@ -600,17 +738,30 @@ module KorpCraft end def nearest_contour_segment(view, x, y) - return nil unless @params['groove_enabled'] && view&.respond_to?(:screen_coords) - cursor = [x.to_f, y.to_f] - best = nil - @contour_spec.segments.each_with_index do |segment, index| - next unless segment.support_panel - first = view.screen_coords(segment.start_point) - second = view.screen_coords(segment.end_point) - distance = screen_segment_distance(cursor, [first.x.to_f, first.y.to_f], [second.x.to_f, second.y.to_f]) - best = [distance, index] if best.nil? || distance < best[0] - end - best && best[0] <= 10.0 ? best[1] : nil + return nil unless view&.respond_to?(:screen_coords) && @contour_spec&.valid? + in_offset = @offset_selection_mode == true + return nil unless (@params['groove_enabled'] || in_offset) + cursor = [x.to_f, y.to_f] + best = nil + if in_offset + points = KorpCraft::Features::BackPanel::ContourBuilder.canonical_outer_loop(@contour_spec).points + points.each_index do |index| + first = view.screen_coords(points[index]) + second = view.screen_coords(points[(index + 1) % points.length]) + distance = screen_segment_distance(cursor, [first.x.to_f, first.y.to_f], [second.x.to_f, second.y.to_f]) + best = [distance, index] if best.nil? || distance < best[0] + end + else + @contour_spec.segments.each_with_index do |segment, index| + next unless segment.support_panel + + first = view.screen_coords(segment.start_point) + second = view.screen_coords(segment.end_point) + distance = screen_segment_distance(cursor, [first.x.to_f, first.y.to_f], [second.x.to_f, second.y.to_f]) + best = [distance, index] if best.nil? || distance < best[0] + end + end + best && best[0] <= 12.0 ? best[1] : nil rescue StandardError nil end @@ -627,9 +778,10 @@ module KorpCraft Math.sqrt((point[0] - px)**2 + (point[1] - py)**2) end - def draw_trim_candidates(view) + def draw_trim_candidates(view, preview_contour = @contour_spec) model = Sketchup.active_model - candidates = KorpCraft::Features::BackPanel::BooleanService.preview_trim_candidates(model, @contour_spec, @params) + candidates = KorpCraft::Features::BackPanel::BooleanService.preview_trim_candidates(model, preview_contour, @params) + return if candidates.empty? view.drawing_color = Sketchup::Color.new(255, 145, 20) view.line_width = 3 candidates.each { |entity| draw_bounds(view, entity.bounds) } @@ -640,6 +792,22 @@ module KorpCraft edges.each { |a, b| view.draw(GL_LINES, [bounds.corner(a), bounds.corner(b)]) } end + def draw_offset_edges(view) + return unless @contour_spec&.valid? + + points = KorpCraft::Features::BackPanel::ContourBuilder.canonical_outer_loop(@contour_spec).points + return if points.length < 3 + + selected = Set.new((@params['selected_offset_edges'] || []).map(&:to_i)) + use_all = selected.empty? + points.each_index do |index| + active = use_all || selected.include?(index) + view.drawing_color = active ? Sketchup::Color.new(255, 100, 100, 200) : Sketchup::Color.new(100, 150, 255, 150) + view.line_width = active ? 8 : 5 + view.draw(GL_LINES, [points[index], points[(index + 1) % points.length]]) + end + end + def world_transform(path) Array(path).inject(Geom::Transformation.new) do |memo, entity| if entity.respond_to?(:transformation) @@ -729,15 +897,21 @@ module KorpCraft def update_status text = case @selection_phase when :ready - 'Задняя стенка: контур готов. Клик по стороне переключает паз.' + if !offset_valid? + 'Задняя стенка: недопустимый отступ. Измените значение.' + elsif @offset_selection_mode + 'Задняя стенка: выберите грани для отступа. Enter завершает выбор.' + else + 'Задняя стенка: контур готов. Клик по стороне переключает паз.' + end when :choosing "Задняя стенка: найдено #{@candidate_specs.length} контуров. Наведите и кликните; Tab переключает." else 'Задняя стенка: выбирайте рёбра. Enter завершает выбор.' end Sketchup.set_status_text(text, SB_PROMPT) - rescue StandardError - nil + rescue StandardError + nil end def sync_state diff --git a/features/back_panel/offset_builder.rb b/features/back_panel/offset_builder.rb new file mode 100644 index 0000000..ef7be53 --- /dev/null +++ b/features/back_panel/offset_builder.rb @@ -0,0 +1,237 @@ +module KorpCraft + module Features + module BackPanel + module OffsetBuilder + Result = Struct.new(:contour, :valid, :reason, keyword_init: true) do + def valid? + valid == true + end + end + + module_function + + def apply_offset(contour, offset_mm = 0.0, selected_edges = []) + result = build(contour, offset_mm, selected_edges) + result.valid? ? result.contour : contour + end + + def build(contour, offset_mm = 0.0, selected_edges = []) + return invalid_result(contour, 'invalid_contour') unless contour&.valid? + + offset_value = offset_mm.to_f + return Result.new(contour: contour, valid: true, reason: '') if offset_value.abs <= 0.01 + + canonical = ContourBuilder.canonical_outer_loop(contour) + points = canonical.points + return invalid_result(contour, 'invalid_canonical_loop') if points.length < 3 + + axes = ContourBuilder.plane_axes(points, contour.normal) + return invalid_result(contour, 'invalid_offset_plane') unless axes + + origin, x_axis, y_axis = axes + coordinates = points.map { |point| project(point, origin, x_axis, y_axis) } + original_area = polygon_area(coordinates) + return invalid_result(contour, 'degenerate_offset_source') if original_area.abs <= 1.0e-9 + + selected = normalize_selected_edges(selected_edges, coordinates.length) + use_all = selected.empty? + selected_set = selected.each_with_object({}) { |index, memo| memo[index] = true } + shifted = shifted_edges(coordinates, original_area, model_length(offset_value), selected_set, use_all) + offset_coordinates = intersect_shifted_edges(shifted) + return invalid_result(contour, 'offset_parallel_edges') unless offset_coordinates + + reason = validation_error(coordinates, offset_coordinates, original_area, use_all, offset_value) + return invalid_result(contour, reason) if reason + + rebuilt_points = restore_segmented_points( + contour, canonical, coordinates, offset_coordinates, origin, x_axis, y_axis + ) + return invalid_result(contour, 'offset_mapping_failed') unless rebuilt_points + + sources = contour.segments.map do |segment| + { + source: segment.source, + support_panel: segment.support_panel, + groove_excluded: segment.groove_excluded + } + end + rebuilt = ContourBuilder.from_points( + rebuilt_points, + sources: sources, + normal_hint: contour.normal, + groove_enabled: contour.segments.any?(&:groove) + ) + return invalid_result(contour, rebuilt.reason.empty? ? 'invalid_offset_contour' : rebuilt.reason) unless rebuilt.valid? + + Result.new(contour: rebuilt, valid: true, reason: '') + rescue StandardError => e + KorpCraft::Core::FaultTolerance.fallback('offset_builder_build', e) + invalid_result(contour, 'offset_failed') + end + + def normalize_selected_edges(selected_edges, edge_count) + Array(selected_edges).filter_map do |value| + index = Integer(value) + index if index >= 0 && index < edge_count + rescue StandardError + nil + end.uniq + end + + def shifted_edges(coordinates, signed_area, offset, selected_set, use_all) + orientation = signed_area.negative? ? -1.0 : 1.0 + coordinates.each_index.map do |index| + first = coordinates[index] + second = coordinates[(index + 1) % coordinates.length] + dx = second[0] - first[0] + dy = second[1] - first[1] + length = Math.sqrt(dx * dx + dy * dy) + raise 'degenerate_offset_edge' if length <= 1.0e-9 + + amount = use_all || selected_set[index] ? offset : 0.0 + outward = orientation.positive? ? [dy / length, -dx / length] : [-dy / length, dx / length] + inward = [-outward[0], -outward[1]] + [ + [first[0] + inward[0] * amount, first[1] + inward[1] * amount], + [second[0] + inward[0] * amount, second[1] + inward[1] * amount] + ] + end + end + + def intersect_shifted_edges(shifted) + shifted.each_index.map do |index| + previous = shifted[(index - 1) % shifted.length] + current = shifted[index] + intersection = line_intersection(previous[0], previous[1], current[0], current[1]) + return nil unless intersection && intersection.all?(&:finite?) + + intersection + end + end + + def validation_error(original, candidate, original_area, use_all, offset_value) + candidate_area = polygon_area(candidate) + return 'offset_collapsed' if candidate_area.abs <= 1.0e-9 + return 'offset_orientation_changed' unless original_area * candidate_area > 0.0 + return 'offset_orientation_changed' unless edge_directions_preserved?(original, candidate) + return 'offset_not_inset' if use_all && offset_value.positive? && candidate_area.abs >= original_area.abs - 1.0e-9 + return 'offset_outside_contour' if offset_value.positive? && candidate.any? { |point| !inside_or_boundary?(point, original) } + + nil + end + + def edge_directions_preserved?(original, candidate) + original.each_index.all? do |index| + old_first = original[index] + old_second = original[(index + 1) % original.length] + new_first = candidate[index] + new_second = candidate[(index + 1) % candidate.length] + old_dx = old_second[0] - old_first[0] + old_dy = old_second[1] - old_first[1] + new_dx = new_second[0] - new_first[0] + new_dy = new_second[1] - new_first[1] + old_dx * new_dx + old_dy * new_dy > 1.0e-9 + end + end + + def restore_segmented_points(contour, canonical, original, offset, origin, x_axis, y_axis) + restored = Array.new(contour.points.length) + canonical.segment_groups.each_with_index do |group, canonical_index| + old_start = original[canonical_index] + old_finish = original[(canonical_index + 1) % original.length] + new_start = offset[canonical_index] + new_finish = offset[(canonical_index + 1) % offset.length] + old_dx = old_finish[0] - old_start[0] + old_dy = old_finish[1] - old_start[1] + old_length_squared = old_dx * old_dx + old_dy * old_dy + return nil if old_length_squared <= 1.0e-12 + + group.each do |segment_index| + raw = project(contour.points[segment_index], origin, x_axis, y_axis) + factor = ((raw[0] - old_start[0]) * old_dx + (raw[1] - old_start[1]) * old_dy) / old_length_squared + x_value = new_start[0] + (new_finish[0] - new_start[0]) * factor + y_value = new_start[1] + (new_finish[1] - new_start[1]) * factor + restored[segment_index] = point_from_axes(origin, x_axis, y_axis, x_value, y_value) + end + end + restored.all? ? restored : nil + end + + def inside_or_boundary?(point, polygon) + return true if polygon.each_index.any? do |index| + point_on_segment?(point, polygon[index], polygon[(index + 1) % polygon.length]) + end + + inside = false + previous = polygon.length - 1 + polygon.each_index do |index| + first = polygon[index] + second = polygon[previous] + crosses = (first[1] > point[1]) != (second[1] > point[1]) + if crosses + x_at_y = (second[0] - first[0]) * (point[1] - first[1]) / + (second[1] - first[1]) + first[0] + inside = !inside if point[0] < x_at_y + end + previous = index + end + inside + end + + def point_on_segment?(point, first, second) + dx = second[0] - first[0] + dy = second[1] - first[1] + length_squared = dx * dx + dy * dy + return false if length_squared <= 1.0e-12 + + factor = ((point[0] - first[0]) * dx + (point[1] - first[1]) * dy) / length_squared + return false unless factor >= -1.0e-7 && factor <= 1.0 + 1.0e-7 + + projected_x = first[0] + factor * dx + projected_y = first[1] + factor * dy + Math.sqrt((point[0] - projected_x)**2 + (point[1] - projected_y)**2) <= 1.0e-7 + end + + def project(point, origin, x_axis, y_axis) + vector = origin.vector_to(point) + [vector.dot(x_axis), vector.dot(y_axis)] + end + + def polygon_area(points) + points.each_index.sum do |index| + first = points[index] + second = points[(index + 1) % points.length] + first[0] * second[1] - first[1] * second[0] + end / 2.0 + end + + def line_intersection(first_start, first_finish, second_start, second_finish) + denominator = (first_start[0] - first_finish[0]) * (second_start[1] - second_finish[1]) - + (first_start[1] - first_finish[1]) * (second_start[0] - second_finish[0]) + return nil if denominator.abs <= 1.0e-9 + + first_det = first_start[0] * first_finish[1] - first_start[1] * first_finish[0] + second_det = second_start[0] * second_finish[1] - second_start[1] * second_finish[0] + [ + (first_det * (second_start[0] - second_finish[0]) - + (first_start[0] - first_finish[0]) * second_det) / denominator, + (first_det * (second_start[1] - second_finish[1]) - + (first_start[1] - first_finish[1]) * second_det) / denominator + ] + end + + def point_from_axes(origin, x_axis, y_axis, x_value, y_value) + origin.offset(x_axis, x_value).offset(y_axis, y_value) + end + + def model_length(mm) + mm.to_f.respond_to?(:mm) ? mm.to_f.mm.to_f : mm.to_f / 25.4 + end + + def invalid_result(contour, reason) + Result.new(contour: contour, valid: false, reason: reason.to_s) + end + end + end + end +end diff --git a/spec/back_panel_regression_spec.rb b/spec/back_panel_regression_spec.rb new file mode 100644 index 0000000..ded691d --- /dev/null +++ b/spec/back_panel_regression_spec.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +require 'singleton' +require 'spec_helper' +require 'core/fault_tolerance' +require 'features/back_panel/constants' +require 'features/back_panel/params_normalizer' +require 'core/ui_state' +require 'features/back_panel/state_repository' +require 'features/back_panel/undo_chain' +require 'features/back_panel/contour_spec' +require 'features/back_panel/contour_builder' +require 'features/back_panel/geometry_builder' +require 'features/back_panel/offset_builder' +require 'features/back_panel/boolean_service' +require 'features/back_panel/commands/base_command' +require 'features/back_panel/commands/commit_command' +require 'tools/back_panel_tool' +require 'features/back_panel/dialog_manager' + +RSpec.describe 'BackPanel regression: offset + contour canonicalization' do + # Helpers to build points in fake_geom + def pt(x, y, z = 0) + Geom::Point3d.new(x, y, z) + end + + def contour_from_points(points) + KorpCraft::Features::BackPanel::ContourBuilder.from_points(points, normal_hint: Geom::Vector3d.new(0, 1, 0)) + end + + def shuffled_segment_hashes(points) + # Build closed loop hashes in random order to exercise from_segments ordering / trimming + segs = points.each_index.map { |i| { start: points[i], finish: points[(i+1)%points.length] } } + segs.shuffle(random: Random.new) + end + + # ---- Generators ---- + + def random_convex_polygon(n, radius: 400, seed: nil) + rng = seed ? Random.new(seed) : Random.new + angles = Array.new(n) { rng.rand * 2*Math::PI }.sort + radii = Array.new(n) { radius * (0.6 + rng.rand*0.4) } + angles.each_with_index.map { |a,i| pt(Math.cos(a)*radii[i], 0, Math.sin(a)*radii[i]) } + end + + def circle_polygon(center, r, segments: 16) + (0...segments).map do |i| + a = 2*Math::PI * i / segments + pt(center.x + Math.cos(a)*r, center.y, center.z + Math.sin(a)*r) + end + end + + def rounded_rect_polygon(w, h, r, corner_segments: 4) + # CCW from origin + pts = [] + pts << pt(r, 0, 0) + pts << pt(w - r, 0, 0) + # top-right arc r + corner_segments.times do |i| + a = -Math::PI/2 + Math::PI/2 * (i+1).to_f / (corner_segments+1) + pts << pt(w - r + Math.cos(a)*r, 0, r + Math.sin(a)*r) + end + pts << pt(w, 0, r) + pts << pt(w, 0, h - r) + corner_segments.times do |i| + a = 0 + Math::PI/2 * (i+1).to_f / (corner_segments+1) + pts << pt(w - r + Math.cos(a)*r, 0, h - r + Math.sin(a)*r) + end + pts << pt(w - r, 0, h) + pts << pt(r, 0, h) + corner_segments.times do |i| + a = Math::PI/2 + Math::PI/2 * (i+1).to_f / (corner_segments+1) + pts << pt(r + Math.cos(a)*r, 0, h - r + Math.sin(a)*r) + end + pts << pt(0, 0, h - r) + pts << pt(0, 0, r) + corner_segments.times do |i| + a = Math::PI + Math::PI/2 * (i+1).to_f / (corner_segments+1) + pts << pt(r + Math.cos(a)*r, 0, r + Math.sin(a)*r) + end + pts + end + + def spline_like_polygon(seed: 42, segments: 30, scale: 300) + # Closed cubic-bezier-like wavy shape approximated as polyline + rng = Random.new(seed) + base = (0...segments).map do |i| + t = 2*Math::PI * i / segments + # two harmonics => smooth spline feel + r = scale * (1 + 0.15*Math.sin(3*t + rng.rand) + 0.08*Math.cos(5*t)) + pt(Math.cos(t)*r, 0, Math.sin(t)*r) + end + base + end + + def with_collinear_splits(points, splits_per_edge: 1, seed: nil) + # Insert intermediate collinear points to simulate "segmented wall" (e.g. 8 pts -> 4 canonical) + rng = seed ? Random.new(seed) : Random.new + out = [] + points.each_index do |i| + a = points[i] + b = points[(i+1)%points.length] + out << a + Array.new(splits_per_edge) { rng.rand * 0.6 + 0.2 }.sort.each do |t| + out << Geom::Point3d.new(a.x*(1-t)+b.x*t, a.y, a.z*(1-t)+b.z*t) + end + end + out + end + + # ---- Shared invariants ---- + + def assert_uniform_inset(original, offset_mm: 5.0) + mod = KorpCraft::Features::BackPanel::OffsetBuilder + result = mod.build(original, offset_mm, []) + expect(result).to be_valid, result.reason + r = result.contour + expect(r).to be_valid + expect(r.points.length).to eq(original.points.length).or be >= 3 # canonical length preserved for simple shapes + # area must reduce for valid convex inset with small offset + builder = KorpCraft::Features::BackPanel::ContourBuilder + # compute area via builder plane + axes = builder.plane_axes(original.points, original.normal) + r_axes = builder.plane_axes(r.points, r.normal) + if axes && r_axes + o, xa, ya = axes + ro, rxa, rya = r_axes + coords = original.points.map { |p| v = o.vector_to(p); [v.dot(xa), v.dot(ya)] } + rcoords = r.points.map { |p| v = ro.vector_to(p); [v.dot(rxa), v.dot(rya)] } + orig_area = mod.polygon_area(coords).abs + new_area = mod.polygon_area(rcoords).abs + expect(new_area).to be < orig_area + end + r + end + + it 'keeps source segments while offsetting four canonical sides' do + rect = [pt(0,0,0), pt(600,0,0), pt(600,0,400), pt(0,0,400)] + split = with_collinear_splits(rect, splits_per_edge: 1) # 8 pts + expect(split.length).to eq(8) + spec = contour_from_points(split) + expect(spec).to be_valid + expect(spec.points.length).to eq(8) + expect(KorpCraft::Features::BackPanel::ContourBuilder.canonical_outer_loop(spec).points.length).to eq(4) + r = assert_uniform_inset(spec, offset_mm: 5.0) + expect(r.points.length).to eq(8) + end + + it 'preserves support and groove metadata across a canonical edge' do + first_support = Object.new + second_support = Object.new + points = [pt(0,0,0), pt(50,0,0), pt(100,0,0), pt(100,0,100), pt(0,0,100)] + sources = [ + { source: :first, support_panel: first_support }, + { source: :second, support_panel: second_support }, + {}, {}, {} + ] + spec = KorpCraft::Features::BackPanel::ContourBuilder.from_points( + points, + sources: sources, + normal_hint: Geom::Vector3d.new(0, 1, 0), + groove_enabled: true + ) + + expect(spec.points.length).to eq(5) + expect(spec.selected_support_panels).to contain_exactly(first_support, second_support) + expect(spec.grooved_segments.length).to eq(2) + expect(KorpCraft::Features::BackPanel::ContourBuilder.canonical_outer_loop(spec).segment_groups.first).to eq([0, 1]) + + offset = KorpCraft::Features::BackPanel::OffsetBuilder.build(spec, 5.0, []) + expect(offset).to be_valid, offset.reason + expect(offset.contour.points.length).to eq(5) + expect(offset.contour.selected_support_panels).to contain_exactly(first_support, second_support) + expect(offset.contour.grooved_segments.length).to eq(2) + end + + it 'rejects collapsed or inverted insets' do + mm = ->(value) { value.to_f / 25.4 } + square = contour_from_points([ + pt(mm.call(0), 0, mm.call(0)), pt(mm.call(100), 0, mm.call(0)), + pt(mm.call(100), 0, mm.call(100)), pt(mm.call(0), 0, mm.call(100)) + ]) + + valid = KorpCraft::Features::BackPanel::OffsetBuilder.build(square, 49.0, []) + invalid = KorpCraft::Features::BackPanel::OffsetBuilder.build(square, 60.0, []) + + expect(valid).to be_valid, valid.reason + expect(invalid).not_to be_valid + expect(invalid.reason).to eq('offset_orientation_changed') + expect(KorpCraft::Features::BackPanel::OffsetBuilder.apply_offset(square, 60.0, [])).to equal(square) + end + + it 'rejects a collinear backtracking contour' do + spec = contour_from_points([ + pt(0,0,0), pt(60,0,0), pt(40,0,0), pt(100,0,0), pt(100,0,100), pt(0,0,100) + ]) + + expect(spec).not_to be_valid + expect(spec.reason).to eq('backtracking_edge') + end + + it 'keeps a concave corner-cutout valid' do + spec = contour_from_points([ + pt(0,0,0), pt(100,0,0), pt(100,0,40), + pt(60,0,40), pt(60,0,80), pt(0,0,80) + ]) + + result = KorpCraft::Features::BackPanel::OffsetBuilder.build(spec, 2.0, []) + + expect(result).to be_valid, result.reason + expect(result.contour.points.length).to eq(6) + end + + it 'random closed contours 5..50 segments: valid and uniform inset, shuffled order' do + rng = Random.new(1337) + 30.times do |i| + n = rng.rand(5..50) + pts = random_convex_polygon(n, seed: rng.rand(1<<30)) + # via points (canonical) + via shuffled segments + spec = contour_from_points(pts) + expect(spec).to be_valid, "iter #{i} n=#{n} points valid failed" + expect(spec.points.length).to eq(n).or be >= 3 + + segs = shuffled_segment_hashes(pts) + spec2 = KorpCraft::Features::BackPanel::ContourBuilder.from_segments(segs) + expect(spec2).to be_valid, "iter #{i} shuffled segments should close" + + [spec, spec2].each { |s| assert_uniform_inset(s, offset_mm: 3.0) } + # partial offset (single edge) keeps same vertex count + mod = KorpCraft::Features::BackPanel::OffsetBuilder + r_partial = mod.apply_offset(spec, 2.0, [0]) + expect(r_partial).to be_valid + expect(r_partial.points.length).to eq(spec.points.length) + end + end + + it 'circle approximated as polygon (8..32 segments) has uniform inset' do + [8, 12, 16, 24, 32].each do |n| + c = circle_polygon(pt(0,0,0), 200, segments: n) + spec = contour_from_points(c) + expect(spec).to be_valid + expect(spec.points.length).to eq(n) + assert_uniform_inset(spec, offset_mm: 4.0) + shuffled = shuffled_segment_hashes(c) + spec2 = KorpCraft::Features::BackPanel::ContourBuilder.from_segments(shuffled) + expect(spec2).to be_valid + end + end + + it 'rounded rectangles (radii) 4 corners with arcs: uniform inset and canonicalization tolerant' do + [ + [600,400, 20], + [800,500, 50], + [300,300, 80], + ].each do |(w,h,r)| + pts = rounded_rect_polygon(w,h,r, corner_segments: 5) + spec = contour_from_points(pts) + expect(spec).to be_valid + # number of points grows with corner_segments but stays stable + r = assert_uniform_inset(spec, offset_mm: 5.0) + expect(r).to be_valid + # collinear split variant still canonicalizes correctly (rect edges split) + split = with_collinear_splits(pts, splits_per_edge: 1, seed: 7) + spec3 = contour_from_points(split) + expect(spec3).to be_valid + end + end + + it 'spline-like wavy closed contours (spline approximation): offset uniform' do + [10, 20, 30, 50].each do |n| + pts = spline_like_polygon(seed: 100+n, segments: n, scale: 250) + spec = contour_from_points(pts) + expect(spec).to be_valid + # For wavy shapes offset 2mm keeps valid (5mm may self-intersect on tight wavies) + assert_uniform_inset(spec, offset_mm: 2.0) + end + end + + it 'uses canonical edge indexes for partial selection' do + rect = [pt(0,0,0), pt(600,0,0), pt(600,0,400), pt(0,0,400)] + split8 = with_collinear_splits(rect, splits_per_edge: 1) + spec = contour_from_points(split8) + expect(spec.points.length).to eq(8) + expect(KorpCraft::Features::BackPanel::ContourBuilder.canonical_outer_loop(spec).points.length).to eq(4) + mod = KorpCraft::Features::BackPanel::OffsetBuilder + r_all = mod.apply_offset(spec, 5.0, []) + r_two = mod.apply_offset(spec, 5.0, [0,1]) + # r_all must be strictly smaller than r_two in area + builder = KorpCraft::Features::BackPanel::ContourBuilder + axes_all = builder.plane_axes(r_all.points, r_all.normal) + axes_two = builder.plane_axes(r_two.points, r_two.normal) + o1, xa1, ya1 = axes_all + o2, xa2, ya2 = axes_two + area_all = mod.polygon_area(r_all.points.map{|p| v=o1.vector_to(p); [v.dot(xa1), v.dot(ya1)]}).abs + area_two = mod.polygon_area(r_two.points.map{|p| v=o2.vector_to(p); [v.dot(xa2), v.dot(ya2)]}).abs + expect(area_all).to be < area_two + end + + it 'fuzz: 20 random polygons with collinear splits still uniform' do + rng = Random.new(999) + 20.times do + n = rng.rand(5..25) + pts = random_convex_polygon(n, seed: rng.rand(1<<30)) + pts_split = with_collinear_splits(pts, splits_per_edge: rng.rand(1..2), seed: rng.rand(1<<30)) + spec = contour_from_points(pts_split) + expect(spec).to be_valid + # canonical length must be <= n + arc points, and no collinear remain + # uniform inset + assert_uniform_inset(spec, offset_mm: 3.0) + end + end +end + +RSpec.describe KorpCraft::Features::BackPanel::StateRepository do + subject(:repository) { described_class.instance } + + it 'clears persisted edge indexes on load' do + stored = { 'offset_mm' => 5.0, 'selected_offset_edges' => [0, 3, 7] }.to_json + allow(KorpCraft::Core::UiState).to receive(:safe_read_default).and_return(stored) + allow(KorpCraft::Core::UiState).to receive(:decode_pref_payload) { |value| value } + expect(KorpCraft::Core::UiState).to receive(:safe_write_default) do |_key, value| + expect(JSON.parse(value)['selected_offset_edges']).to eq([]) + end + + expect(repository.load_params['selected_offset_edges']).to eq([]) + end + + it 'returns transient indexes but never persists them' do + persisted = nil + allow(KorpCraft::Core::UiState).to receive(:safe_write_default) { |_key, value| persisted = JSON.parse(value) } + + result = repository.save_params('offset_mm' => 5.0, 'selected_offset_edges' => [1, 3]) + + expect(result['selected_offset_edges']).to eq([1, 3]) + expect(persisted['selected_offset_edges']).to eq([]) + end +end + +RSpec.describe KorpCraft::Tools::BackPanelTool do + def preview_contour + KorpCraft::Features::BackPanel::ContourBuilder.from_points( + [ + Geom::Point3d.new(0, 0, 0), Geom::Point3d.new(100, 0, 0), + Geom::Point3d.new(100, 0, 100), Geom::Point3d.new(0, 0, 100) + ], + normal_hint: Geom::Vector3d.new(0, 1, 0) + ) + end + + it 'always draws groove and trim previews' do + tool = described_class.new + contour = preview_contour + tool.instance_variable_set(:@contour_spec, contour) + tool.instance_variable_set(:@selection_phase, :ready) + view = double('view').as_null_object + stub_const('Sketchup', Module.new) unless defined?(Sketchup) + stub_const('Sketchup::Color', Class.new) + stub_const('GL_POLYGON', 1) unless defined?(GL_POLYGON) + stub_const('GL_LINE_LOOP', 2) unless defined?(GL_LINE_LOOP) + stub_const('GL_LINES', 3) unless defined?(GL_LINES) + allow(Sketchup::Color).to receive(:new).and_return(Object.new) + + expect(tool).to receive(:draw_groove_preview) + expect(tool).to receive(:draw_trim_candidates).with(view, contour) + + tool.send(:draw_preview, view) + end +end + +RSpec.describe KorpCraft::Features::BackPanel::DialogManager do + it 'marks an inverted offset invalid for commit' do + mm = ->(value) { value.to_f / 25.4 } + contour = KorpCraft::Features::BackPanel::ContourBuilder.from_points( + [ + Geom::Point3d.new(mm.call(0), 0, mm.call(0)), + Geom::Point3d.new(mm.call(100), 0, mm.call(0)), + Geom::Point3d.new(mm.call(100), 0, mm.call(100)), + Geom::Point3d.new(mm.call(0), 0, mm.call(100)) + ], + normal_hint: Geom::Vector3d.new(0, 1, 0) + ) + params = KorpCraft::Features::BackPanel::ParamsNormalizer.normalize('offset_mm' => 60.0) + tool = KorpCraft::Tools::BackPanelTool.new(params: params) + tool.instance_variable_set(:@contour_spec, contour) + tool.instance_variable_set(:@selection_phase, :ready) + KorpCraft::Tools::BackPanelTool.active_instance = tool + + state = described_class.send(:allocate).build_state(params: params) + + expect(state[:offset_valid]).to be(false) + expect(state[:offset_reason]).to eq('offset_orientation_changed') + ensure + KorpCraft::Tools::BackPanelTool.active_instance = nil + end +end + +RSpec.describe KorpCraft::Features::BackPanel::Commands::CommitCommand do + it 'rejects invalid offset before starting an operation' do + mm = ->(value) { value.to_f / 25.4 } + contour = KorpCraft::Features::BackPanel::ContourBuilder.from_points( + [ + Geom::Point3d.new(mm.call(0), 0, mm.call(0)), + Geom::Point3d.new(mm.call(100), 0, mm.call(0)), + Geom::Point3d.new(mm.call(100), 0, mm.call(100)), + Geom::Point3d.new(mm.call(0), 0, mm.call(100)) + ], + normal_hint: Geom::Vector3d.new(0, 1, 0) + ) + params = KorpCraft::Features::BackPanel::ParamsNormalizer.normalize('offset_mm' => 60.0) + tool = KorpCraft::Tools::BackPanelTool.new(params: params) + tool.instance_variable_set(:@contour_spec, contour) + KorpCraft::Tools::BackPanelTool.active_instance = tool + operation_manager = double('operation manager') + expect(operation_manager).not_to receive(:start) + command = described_class.new( + dialog_manager: double('dialog manager'), + state_repository: double('state repository', save_params: params), + operation_manager: operation_manager + ) + + expect(command.call('params' => params)).to be(false) + ensure + KorpCraft::Tools::BackPanelTool.active_instance = nil + end +end